@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.
Files changed (500) hide show
  1. package/CHANGELOG.md +458 -0
  2. package/LICENSE +21 -0
  3. package/README.md +1359 -0
  4. package/bunfig.toml +2 -0
  5. package/dist/apple-photos-client.js +704 -0
  6. package/dist/beeper-client.js +4602 -0
  7. package/dist/client.js +1605 -0
  8. package/dist/index-26yq8q16.js +31 -0
  9. package/dist/index-9t13b4k7.js +508 -0
  10. package/dist/index-axwm9b3v.js +5 -0
  11. package/dist/index-gwk7rbyj.js +30 -0
  12. package/dist/index.js +701 -0
  13. package/dist/messaging.js +1130 -0
  14. package/dist/omni-client.js +1406 -0
  15. package/dist/whatsapp-client.js +355 -0
  16. package/docs/imessage-direct-provider.md +106 -0
  17. package/docs/rental-listings.md +94 -0
  18. package/package.json +600 -0
  19. package/skills/ghostget/SKILL.md +251 -0
  20. package/skills/ghostget/agents/openai.yaml +4 -0
  21. package/skills/ghostget/references/adapter-contract.md +181 -0
  22. package/skills/ghostget/references/apple-photos.md +115 -0
  23. package/skills/ghostget/references/article-drafts.md +235 -0
  24. package/skills/ghostget/references/code-owned-provider-scaffold.md +134 -0
  25. package/skills/ghostget/references/cross-posting-video.md +139 -0
  26. package/skills/ghostget/references/cross-posting.md +82 -0
  27. package/skills/ghostget/references/derivation.md +390 -0
  28. package/skills/ghostget/references/hraness-social-profile-stats.json +269 -0
  29. package/skills/ghostget/references/install.md +66 -0
  30. package/skills/ghostget/references/linkedin-adapter.md +442 -0
  31. package/skills/ghostget/references/messaging.md +168 -0
  32. package/skills/ghostget/references/meta-comet-contract.md +153 -0
  33. package/skills/ghostget/references/platform-patterns.md +122 -0
  34. package/skills/ghostget/references/provider-plugins.md +496 -0
  35. package/skills/ghostget/references/reddit-flair.md +78 -0
  36. package/skills/ghostget/references/safety-and-state.md +214 -0
  37. package/skills/ghostget/references/settlement-and-duplicate-cleanup.md +79 -0
  38. package/skills/ghostget/references/social-platform-routing.md +66 -0
  39. package/skills/ghostget/references/social-profile-stats.md +195 -0
  40. package/skills/ghostget/references/social-video-platform-routing.md +89 -0
  41. package/skills/ghostget/references/whatsapp-adapter.md +101 -0
  42. package/skills/ghostget/references/x-adapter.md +332 -0
  43. package/skills/ghostget/references/x-ai-disclosure.md +71 -0
  44. package/src/apple-photos-cli.ts +639 -0
  45. package/src/apple-photos-client-types.ts +168 -0
  46. package/src/apple-photos-client.ts +201 -0
  47. package/src/apple-photos-contact-evidence.ts +838 -0
  48. package/src/apple-photos-local-source.ts +1131 -0
  49. package/src/args.ts +2053 -0
  50. package/src/article-draft-document.ts +417 -0
  51. package/src/article-draft-embeds.ts +123 -0
  52. package/src/article-draft-images.ts +214 -0
  53. package/src/assets/adapter-template/wrench-adapter.json +40 -0
  54. package/src/assets/adapters/beeper/wrench-web-adapter.json +1500 -0
  55. package/src/assets/adapters/beeper/wrench-web-adapter.v1.0.0.json +129 -0
  56. package/src/assets/adapters/beeper/wrench-web-adapter.v1.1.0.json +207 -0
  57. package/src/assets/adapters/beeper/wrench-web-adapter.v2.0.0.json +1463 -0
  58. package/src/assets/adapters/beeper/wrench-web-adapter.v2.1.0.json +1463 -0
  59. package/src/assets/adapters/beeper/wrench-web-adapter.v2.2.0.json +1475 -0
  60. package/src/assets/adapters/beeper/wrench-web-adapter.v2.3.0.json +1488 -0
  61. package/src/assets/adapters/bluesky/wrench-web-adapter.json +684 -0
  62. package/src/assets/adapters/bluesky/wrench-web-adapter.v1.0.0.json +581 -0
  63. package/src/assets/adapters/bluesky/wrench-web-adapter.v1.1.0.json +581 -0
  64. package/src/assets/adapters/bluesky/wrench-web-adapter.v1.2.0.json +581 -0
  65. package/src/assets/adapters/bluesky/wrench-web-adapter.v1.3.0.json +615 -0
  66. package/src/assets/adapters/bluesky/wrench-web-adapter.v1.4.0.json +684 -0
  67. package/src/assets/adapters/bluesky/wrench-web-adapter.v1.5.0.json +684 -0
  68. package/src/assets/adapters/bluesky/wrench-web-adapter.v1.6.0.json +684 -0
  69. package/src/assets/adapters/clasificados/wrench-web-adapter.json +54 -0
  70. package/src/assets/adapters/facebook/wrench-web-adapter.json +750 -0
  71. package/src/assets/adapters/facebook/wrench-web-adapter.v1.0.0.json +706 -0
  72. package/src/assets/adapters/facebook/wrench-web-adapter.v1.1.0.json +703 -0
  73. package/src/assets/adapters/facebook/wrench-web-adapter.v1.2.0.json +750 -0
  74. package/src/assets/adapters/facebook-group/wrench-web-adapter.json +682 -0
  75. package/src/assets/adapters/facebook-group/wrench-web-adapter.v1.0.0.json +583 -0
  76. package/src/assets/adapters/facebook-group/wrench-web-adapter.v1.1.0.json +682 -0
  77. package/src/assets/adapters/facebook-marketplace/wrench-web-adapter.json +437 -0
  78. package/src/assets/adapters/facebook-marketplace/wrench-web-adapter.v1.0.0.json +383 -0
  79. package/src/assets/adapters/facebook-marketplace/wrench-web-adapter.v1.1.0.json +437 -0
  80. package/src/assets/adapters/facebook-marketplace/wrench-web-adapter.v1.1.1.json +437 -0
  81. package/src/assets/adapters/facebook-page/wrench-web-adapter.json +880 -0
  82. package/src/assets/adapters/facebook-page/wrench-web-adapter.v1.0.0.json +740 -0
  83. package/src/assets/adapters/facebook-page/wrench-web-adapter.v1.1.0.json +880 -0
  84. package/src/assets/adapters/github/wrench-web-adapter.json +69 -0
  85. package/src/assets/adapters/github/wrench-web-adapter.v1.0.0.json +42 -0
  86. package/src/assets/adapters/github/wrench-web-adapter.v1.1.0.json +69 -0
  87. package/src/assets/adapters/gmail/wrench-adapter.json +161 -0
  88. package/src/assets/adapters/gmail/wrench-adapter.v1.2.0.json +157 -0
  89. package/src/assets/adapters/hacker-news/wrench-web-adapter.json +315 -0
  90. package/src/assets/adapters/hacker-news/wrench-web-adapter.v1.0.0.json +315 -0
  91. package/src/assets/adapters/imessage/wrench-web-adapter.json +236 -0
  92. package/src/assets/adapters/instagram/wrench-web-adapter.json +719 -0
  93. package/src/assets/adapters/instagram/wrench-web-adapter.v1.0.0.json +627 -0
  94. package/src/assets/adapters/instagram/wrench-web-adapter.v1.1.0.json +609 -0
  95. package/src/assets/adapters/instagram/wrench-web-adapter.v1.2.0.json +640 -0
  96. package/src/assets/adapters/instagram/wrench-web-adapter.v1.3.0.json +671 -0
  97. package/src/assets/adapters/instagram/wrench-web-adapter.v1.4.0.json +710 -0
  98. package/src/assets/adapters/instagram/wrench-web-adapter.v1.5.0.json +710 -0
  99. package/src/assets/adapters/instagram/wrench-web-adapter.v1.6.0.json +719 -0
  100. package/src/assets/adapters/instagram/wrench-web-adapter.v1.7.0.json +719 -0
  101. package/src/assets/adapters/linkedin/wrench-adapter.json +442 -0
  102. package/src/assets/adapters/linkedin/wrench-adapter.v0.4.0.json +124 -0
  103. package/src/assets/adapters/linkedin/wrench-adapter.v1.0.0.json +411 -0
  104. package/src/assets/adapters/linkedin/wrench-web-adapter.json +914 -0
  105. package/src/assets/adapters/linkedin/wrench-web-adapter.v1.0.0.json +604 -0
  106. package/src/assets/adapters/linkedin/wrench-web-adapter.v1.1.0.json +604 -0
  107. package/src/assets/adapters/linkedin/wrench-web-adapter.v1.10.0.json +809 -0
  108. package/src/assets/adapters/linkedin/wrench-web-adapter.v1.11.0.json +809 -0
  109. package/src/assets/adapters/linkedin/wrench-web-adapter.v1.12.0.json +820 -0
  110. package/src/assets/adapters/linkedin/wrench-web-adapter.v1.13.0.json +819 -0
  111. package/src/assets/adapters/linkedin/wrench-web-adapter.v1.14.0.json +819 -0
  112. package/src/assets/adapters/linkedin/wrench-web-adapter.v1.15.0.json +819 -0
  113. package/src/assets/adapters/linkedin/wrench-web-adapter.v1.16.0.json +874 -0
  114. package/src/assets/adapters/linkedin/wrench-web-adapter.v1.17.0.json +874 -0
  115. package/src/assets/adapters/linkedin/wrench-web-adapter.v1.18.0.json +874 -0
  116. package/src/assets/adapters/linkedin/wrench-web-adapter.v1.19.0.json +874 -0
  117. package/src/assets/adapters/linkedin/wrench-web-adapter.v1.2.0.json +731 -0
  118. package/src/assets/adapters/linkedin/wrench-web-adapter.v1.3.0.json +768 -0
  119. package/src/assets/adapters/linkedin/wrench-web-adapter.v1.4.0.json +808 -0
  120. package/src/assets/adapters/linkedin/wrench-web-adapter.v1.5.0.json +808 -0
  121. package/src/assets/adapters/linkedin/wrench-web-adapter.v1.6.0.json +808 -0
  122. package/src/assets/adapters/linkedin/wrench-web-adapter.v1.7.0.json +825 -0
  123. package/src/assets/adapters/linkedin/wrench-web-adapter.v1.8.0.json +792 -0
  124. package/src/assets/adapters/linkedin/wrench-web-adapter.v1.9.0.json +809 -0
  125. package/src/assets/adapters/reddit/wrench-web-adapter.json +855 -0
  126. package/src/assets/adapters/reddit/wrench-web-adapter.v1.0.0.json +577 -0
  127. package/src/assets/adapters/reddit/wrench-web-adapter.v1.1.0.json +604 -0
  128. package/src/assets/adapters/reddit/wrench-web-adapter.v1.10.0.json +712 -0
  129. package/src/assets/adapters/reddit/wrench-web-adapter.v1.11.0.json +712 -0
  130. package/src/assets/adapters/reddit/wrench-web-adapter.v1.12.0.json +712 -0
  131. package/src/assets/adapters/reddit/wrench-web-adapter.v1.2.0.json +653 -0
  132. package/src/assets/adapters/reddit/wrench-web-adapter.v1.3.0.json +712 -0
  133. package/src/assets/adapters/reddit/wrench-web-adapter.v1.4.0.json +713 -0
  134. package/src/assets/adapters/reddit/wrench-web-adapter.v1.5.0.json +712 -0
  135. package/src/assets/adapters/reddit/wrench-web-adapter.v1.6.0.json +713 -0
  136. package/src/assets/adapters/reddit/wrench-web-adapter.v1.7.0.json +713 -0
  137. package/src/assets/adapters/reddit/wrench-web-adapter.v1.8.0.json +713 -0
  138. package/src/assets/adapters/reddit/wrench-web-adapter.v1.9.0.json +713 -0
  139. package/src/assets/adapters/substack/wrench-web-adapter.json +936 -0
  140. package/src/assets/adapters/substack/wrench-web-adapter.v1.0.0.json +817 -0
  141. package/src/assets/adapters/substack/wrench-web-adapter.v1.1.0.json +812 -0
  142. package/src/assets/adapters/substack/wrench-web-adapter.v1.2.0.json +812 -0
  143. package/src/assets/adapters/substack/wrench-web-adapter.v1.3.0.json +902 -0
  144. package/src/assets/adapters/substack/wrench-web-adapter.v1.4.0.json +936 -0
  145. package/src/assets/adapters/substack/wrench-web-adapter.v1.5.0.json +936 -0
  146. package/src/assets/adapters/substack/wrench-web-adapter.v1.6.0.json +936 -0
  147. package/src/assets/adapters/threads/wrench-web-adapter.json +692 -0
  148. package/src/assets/adapters/threads/wrench-web-adapter.v1.0.0.json +629 -0
  149. package/src/assets/adapters/threads/wrench-web-adapter.v1.1.0.json +623 -0
  150. package/src/assets/adapters/threads/wrench-web-adapter.v1.2.0.json +622 -0
  151. package/src/assets/adapters/threads/wrench-web-adapter.v1.3.0.json +622 -0
  152. package/src/assets/adapters/threads/wrench-web-adapter.v1.4.0.json +622 -0
  153. package/src/assets/adapters/threads/wrench-web-adapter.v1.5.0.json +699 -0
  154. package/src/assets/adapters/threads/wrench-web-adapter.v1.6.0.json +693 -0
  155. package/src/assets/adapters/threads/wrench-web-adapter.v1.7.0.json +692 -0
  156. package/src/assets/adapters/tiktok/wrench-web-adapter.json +762 -0
  157. package/src/assets/adapters/tiktok/wrench-web-adapter.v1.0.0.json +681 -0
  158. package/src/assets/adapters/tiktok/wrench-web-adapter.v1.1.0.json +708 -0
  159. package/src/assets/adapters/tiktok/wrench-web-adapter.v1.2.0.json +762 -0
  160. package/src/assets/adapters/tiktok/wrench-web-adapter.v1.3.0.json +762 -0
  161. package/src/assets/adapters/twitch/wrench-web-adapter.json +42 -0
  162. package/src/assets/adapters/twitch/wrench-web-adapter.v1.0.0.json +42 -0
  163. package/src/assets/adapters/whatsapp/wrench-web-adapter.json +400 -0
  164. package/src/assets/adapters/whatsapp/wrench-web-adapter.v1.0.0.json +355 -0
  165. package/src/assets/adapters/whatsapp/wrench-web-adapter.v1.1.0.json +386 -0
  166. package/src/assets/adapters/whatsapp/wrench-web-adapter.v1.2.0.json +386 -0
  167. package/src/assets/adapters/whatsapp/wrench-web-adapter.v1.3.0.json +400 -0
  168. package/src/assets/adapters/x/wrench-adapter.json +749 -0
  169. package/src/assets/adapters/x/wrench-adapter.v1.0.0.json +700 -0
  170. package/src/assets/adapters/x/wrench-adapter.v1.1.0.json +707 -0
  171. package/src/assets/adapters/x/wrench-web-adapter.json +712 -0
  172. package/src/assets/adapters/x/wrench-web-adapter.v1.1.0.json +579 -0
  173. package/src/assets/adapters/x/wrench-web-adapter.v1.10.0.json +685 -0
  174. package/src/assets/adapters/x/wrench-web-adapter.v1.11.0.json +712 -0
  175. package/src/assets/adapters/x/wrench-web-adapter.v1.12.0.json +712 -0
  176. package/src/assets/adapters/x/wrench-web-adapter.v1.13.0.json +712 -0
  177. package/src/assets/adapters/x/wrench-web-adapter.v1.2.0.json +578 -0
  178. package/src/assets/adapters/x/wrench-web-adapter.v1.3.0.json +610 -0
  179. package/src/assets/adapters/x/wrench-web-adapter.v1.4.0.json +617 -0
  180. package/src/assets/adapters/x/wrench-web-adapter.v1.5.0.json +634 -0
  181. package/src/assets/adapters/x/wrench-web-adapter.v1.6.0.json +632 -0
  182. package/src/assets/adapters/x/wrench-web-adapter.v1.7.0.json +649 -0
  183. package/src/assets/adapters/x/wrench-web-adapter.v1.8.0.json +649 -0
  184. package/src/assets/adapters/x/wrench-web-adapter.v1.9.0.json +683 -0
  185. package/src/assets/adapters/youtube/wrench-web-adapter.json +561 -0
  186. package/src/assets/adapters/youtube/wrench-web-adapter.v1.0.0.json +465 -0
  187. package/src/assets/adapters/youtube/wrench-web-adapter.v1.1.0.json +519 -0
  188. package/src/assets/adapters/youtube/wrench-web-adapter.v1.2.0.json +561 -0
  189. package/src/assets/adapters/youtube/wrench-web-adapter.v1.3.0.json +561 -0
  190. package/src/assets/code-owned-provider-template/plugin.test.ts.template +54 -0
  191. package/src/assets/code-owned-provider-template/plugin.ts.template +102 -0
  192. package/src/assets/code-owned-provider-template/runtime.internal.test.ts.template +44 -0
  193. package/src/assets/code-owned-provider-template/runtime.ts.template +59 -0
  194. package/src/assets/code-owned-provider-template/wrench-adapter.json.template +33 -0
  195. package/src/assets/fonts/nebula-sans/LICENSE.txt +96 -0
  196. package/src/assets/fonts/nebula-sans/NebulaSans-Book.woff2 +0 -0
  197. package/src/assets/fonts/nebula-sans/PROVENANCE.md +14 -0
  198. package/src/assets.d.ts +4 -0
  199. package/src/auth.ts +1157 -0
  200. package/src/beeper-client-types.ts +169 -0
  201. package/src/beeper-client.ts +250 -0
  202. package/src/beeper-contact-interactions-cli.ts +180 -0
  203. package/src/beeper-contact-interactions.ts +1612 -0
  204. package/src/beeper-message-bundle-v1.ts +257 -0
  205. package/src/beeper-message-like-me-cli.ts +84 -0
  206. package/src/beeper-message-like-me-export.ts +2274 -0
  207. package/src/beeper-message-like-me-recovery.ts +931 -0
  208. package/src/beeper-message-like-me-source.ts +4034 -0
  209. package/src/browser-admission.ts +928 -0
  210. package/src/browser-snapshots.ts +260 -0
  211. package/src/browser.ts +3873 -0
  212. package/src/canonical-json.ts +60 -0
  213. package/src/catalog-cli.ts +638 -0
  214. package/src/cli.ts +208 -0
  215. package/src/client-types.ts +301 -0
  216. package/src/client.ts +2726 -0
  217. package/src/confirmed-write-failure.ts +44 -0
  218. package/src/confirmed-write-model.ts +72 -0
  219. package/src/confirmed-write-platform.ts +1055 -0
  220. package/src/confirmed-write-program.ts +224 -0
  221. package/src/confirmed-write-runtime.ts +16 -0
  222. package/src/cursor-token.ts +228 -0
  223. package/src/derivation-dnr-readiness.ts +367 -0
  224. package/src/derivation-file-chooser.ts +384 -0
  225. package/src/derivation-network-boundary.ts +835 -0
  226. package/src/derivation-network-guard.ts +782 -0
  227. package/src/derivation-network-proxy-helper.ts +440 -0
  228. package/src/derivation-network-proxy.ts +385 -0
  229. package/src/derive-command-helper.ts +693 -0
  230. package/src/derive-fixtures.ts +343 -0
  231. package/src/derive-review.ts +980 -0
  232. package/src/derive.ts +4754 -0
  233. package/src/fixtures/beeper-message-like-me-v1/accounts.ndjson +2 -0
  234. package/src/fixtures/beeper-message-like-me-v1/conversations.ndjson +1 -0
  235. package/src/fixtures/beeper-message-like-me-v1/manifest.json +1 -0
  236. package/src/fixtures/beeper-message-like-me-v1/messages.ndjson +2 -0
  237. package/src/fixtures/beeper-message-like-me-v1/participants.ndjson +3 -0
  238. package/src/fixtures/beeper-message-like-me-v1/reactions.ndjson +1 -0
  239. package/src/fixtures/beeper-message-like-me-v1/tombstones.ndjson +1 -0
  240. package/src/ghostget.ts +3672 -0
  241. package/src/gmail-capture.ts +1244 -0
  242. package/src/har-internal.ts +1242 -0
  243. package/src/har.ts +743 -0
  244. package/src/index.ts +218 -0
  245. package/src/invocation-read-platform.ts +55 -0
  246. package/src/invocation-read-program.ts +93 -0
  247. package/src/invocation-read-runtime.ts +11 -0
  248. package/src/linked-device-lifecycle-admission.ts +450 -0
  249. package/src/linked-device-lifecycle-journal.ts +1560 -0
  250. package/src/linked-device-lifecycle-runtime.ts +1602 -0
  251. package/src/local-cli-admission.ts +135 -0
  252. package/src/local-cli-contracts.ts +271 -0
  253. package/src/local-cli-execution.ts +98 -0
  254. package/src/local-cli-surface-contract.ts +1510 -0
  255. package/src/local-cli-tool-identity.ts +336 -0
  256. package/src/media/archive.ts +2325 -0
  257. package/src/media/args.ts +303 -0
  258. package/src/media/cli.ts +287 -0
  259. package/src/media/doctor.ts +475 -0
  260. package/src/media/ffmpeg.ts +1330 -0
  261. package/src/media/http-capture.ts +711 -0
  262. package/src/media/http-probe.ts +593 -0
  263. package/src/media/http.ts +387 -0
  264. package/src/media/index.ts +10 -0
  265. package/src/media/local-transcription.ts +613 -0
  266. package/src/media/lock.ts +321 -0
  267. package/src/media/manifest.ts +1712 -0
  268. package/src/media/metadata.ts +906 -0
  269. package/src/media/process.ts +723 -0
  270. package/src/media/revision.ts +166 -0
  271. package/src/media/runtime-closure.ts +1063 -0
  272. package/src/media/source-router.ts +187 -0
  273. package/src/media/transcriber-config.ts +1122 -0
  274. package/src/media/transcript-persistence-model.ts +15 -0
  275. package/src/media/transcript-persistence-platform.ts +89 -0
  276. package/src/media/transcript-persistence-program.ts +39 -0
  277. package/src/media/transcript-persistence-runtime.ts +24 -0
  278. package/src/media/transcript.ts +616 -0
  279. package/src/media/utf8-order.ts +30 -0
  280. package/src/media/whisper-cpp.ts +464 -0
  281. package/src/media/whisper-language.ts +58 -0
  282. package/src/media/yt-dlp.ts +439 -0
  283. package/src/message-like-me-agentic-messaging.ts +882 -0
  284. package/src/messaging-action-store.ts +793 -0
  285. package/src/messaging-runtime.ts +2136 -0
  286. package/src/messaging-store.ts +539 -0
  287. package/src/messaging-types.ts +1663 -0
  288. package/src/messaging.ts +334 -0
  289. package/src/model.ts +2671 -0
  290. package/src/oauth-google.ts +888 -0
  291. package/src/omni-client-types.ts +306 -0
  292. package/src/omni-client.ts +2225 -0
  293. package/src/omni-limits.ts +34 -0
  294. package/src/omni-model.ts +1614 -0
  295. package/src/omni-request.ts +379 -0
  296. package/src/omni-runtime.ts +1632 -0
  297. package/src/operation-deadline.ts +189 -0
  298. package/src/path-helper.ts +1656 -0
  299. package/src/pinned-https.ts +194 -0
  300. package/src/plan-assets.ts +832 -0
  301. package/src/platform-catalog.ts +1832 -0
  302. package/src/plugins/beeper-linked-device/plugin.ts +310 -0
  303. package/src/plugins/bluesky-web/plugin.ts +178 -0
  304. package/src/plugins/clasificados-web/plugin.ts +66 -0
  305. package/src/plugins/github-web/plugin.ts +80 -0
  306. package/src/plugins/gmail-official/plugin.ts +110 -0
  307. package/src/plugins/hacker-news-web/plugin.ts +52 -0
  308. package/src/plugins/imessage-direct/plugin.ts +195 -0
  309. package/src/plugins/imessage-direct/vendor/0001-fix-keep-AppleScript-send-payloads-out-of-child-argv.patch +1430 -0
  310. package/src/plugins/imessage-direct/vendor/0002-feat-rpc-add-exact-chat-lookup.patch +225 -0
  311. package/src/plugins/imessage-direct/vendor/provenance.json +80 -0
  312. package/src/plugins/linkedin-official/plugin.ts +84 -0
  313. package/src/plugins/linkedin-web/plugin.ts +412 -0
  314. package/src/plugins/meta-web/plugin.ts +317 -0
  315. package/src/plugins/reddit-web/flair.ts +492 -0
  316. package/src/plugins/reddit-web/plugin.ts +477 -0
  317. package/src/plugins/substack-web/plugin.ts +124 -0
  318. package/src/plugins/tiktok-web/plugin.ts +67 -0
  319. package/src/plugins/twitch-web/plugin.ts +54 -0
  320. package/src/plugins/whatsapp-linked-device/plugin.ts +105 -0
  321. package/src/plugins/x-official/plugin.ts +64 -0
  322. package/src/plugins/x-web/plugin.ts +488 -0
  323. package/src/plugins/youtube-web/plugin.ts +101 -0
  324. package/src/portable-run-recovery.ts +466 -0
  325. package/src/process-identity.ts +501 -0
  326. package/src/profile-clone-helper.ts +104 -0
  327. package/src/provider-catalog-views.ts +51 -0
  328. package/src/provider-context.ts +69 -0
  329. package/src/provider-contract-definitions-x.ts +45 -0
  330. package/src/provider-contract-definitions.ts +948 -0
  331. package/src/provider-contract-input-gmail.ts +176 -0
  332. package/src/provider-contract-input-linkedin.ts +77 -0
  333. package/src/provider-contract-input-x.ts +304 -0
  334. package/src/provider-contract-planning.ts +23 -0
  335. package/src/provider-contract-semantic-identity.ts +54 -0
  336. package/src/provider-contracts.ts +251 -0
  337. package/src/provider-http.ts +723 -0
  338. package/src/provider-plugin-auth.ts +36 -0
  339. package/src/provider-plugin-builtins.ts +229 -0
  340. package/src/provider-plugin-cleanup-barrier.ts +229 -0
  341. package/src/provider-plugin-cleanup-execution.ts +79 -0
  342. package/src/provider-plugin-cleanup-resource.ts +470 -0
  343. package/src/provider-plugin-contract-identity.ts +707 -0
  344. package/src/provider-plugin-host.ts +1972 -0
  345. package/src/provider-plugin-identifiers.ts +60 -0
  346. package/src/provider-plugin-invocation-lease.ts +889 -0
  347. package/src/provider-plugin-lifecycle-kernel.ts +566 -0
  348. package/src/provider-plugin-lifecycle.ts +1455 -0
  349. package/src/provider-plugin-module-analysis.ts +336 -0
  350. package/src/provider-plugin-package.ts +2530 -0
  351. package/src/provider-plugin-portable-authority.ts +106 -0
  352. package/src/provider-plugin-portable-catalog.ts +94 -0
  353. package/src/provider-plugin-portable-identity.ts +315 -0
  354. package/src/provider-plugin-portable-runtime.ts +2057 -0
  355. package/src/provider-plugin-protocol.ts +1625 -0
  356. package/src/provider-plugin-registry.ts +3342 -0
  357. package/src/provider-plugin-store.ts +2155 -0
  358. package/src/provider-plugin-test-fixture.ts +5 -0
  359. package/src/provider-plugin.ts +4907 -0
  360. package/src/provider-plugins.generated.ts +43 -0
  361. package/src/provider-plugins.ts +8 -0
  362. package/src/provider-subject.ts +41 -0
  363. package/src/provider.ts +315 -0
  364. package/src/providers/beeper-local-runtime.ts +5426 -0
  365. package/src/providers/beeper-local.ts +2739 -0
  366. package/src/providers/beeper-messaging.ts +462 -0
  367. package/src/providers/beeper-omni.ts +1159 -0
  368. package/src/providers/bluesky-web-runtime.ts +2895 -0
  369. package/src/providers/bluesky-web.ts +1614 -0
  370. package/src/providers/clasificados-web-runtime.ts +255 -0
  371. package/src/providers/clasificados-web.ts +503 -0
  372. package/src/providers/contact-projection.ts +301 -0
  373. package/src/providers/github-read-model.ts +94 -0
  374. package/src/providers/github-read-platform.ts +165 -0
  375. package/src/providers/github-read-program.ts +97 -0
  376. package/src/providers/github-web-runtime.ts +320 -0
  377. package/src/providers/github-web.ts +403 -0
  378. package/src/providers/gmail-api.ts +2834 -0
  379. package/src/providers/gmail-omni.ts +830 -0
  380. package/src/providers/gmail.ts +1130 -0
  381. package/src/providers/hacker-news-web-runtime.ts +220 -0
  382. package/src/providers/hacker-news-web.ts +809 -0
  383. package/src/providers/imessage-direct-install.ts +354 -0
  384. package/src/providers/imessage-direct-messaging.ts +532 -0
  385. package/src/providers/imessage-direct-omni.ts +400 -0
  386. package/src/providers/imessage-direct-runtime.ts +1498 -0
  387. package/src/providers/imessage-direct.ts +351 -0
  388. package/src/providers/instagram-video-foundations.ts +913 -0
  389. package/src/providers/instagram-web-profile-browser.ts +600 -0
  390. package/src/providers/iso-bmff.ts +338 -0
  391. package/src/providers/linkedin-company-platform.ts +42 -0
  392. package/src/providers/linkedin-company-program.ts +88 -0
  393. package/src/providers/linkedin-contact-failure.ts +84 -0
  394. package/src/providers/linkedin-contact-platform.ts +45 -0
  395. package/src/providers/linkedin-contact-program.ts +91 -0
  396. package/src/providers/linkedin-profile-activity-failure.ts +76 -0
  397. package/src/providers/linkedin-profile-activity-platform.ts +41 -0
  398. package/src/providers/linkedin-profile-activity-program.ts +67 -0
  399. package/src/providers/linkedin-read-failure.ts +52 -0
  400. package/src/providers/linkedin-self-platform.ts +53 -0
  401. package/src/providers/linkedin-self-program.ts +122 -0
  402. package/src/providers/linkedin-web-article-browser.ts +794 -0
  403. package/src/providers/linkedin-web-bootstrap.ts +146 -0
  404. package/src/providers/linkedin-web-contact.ts +830 -0
  405. package/src/providers/linkedin-web-feed-browser.ts +832 -0
  406. package/src/providers/linkedin-web-feed.ts +864 -0
  407. package/src/providers/linkedin-web-post-browser.ts +653 -0
  408. package/src/providers/linkedin-web-profile-browser.ts +683 -0
  409. package/src/providers/linkedin-web-runtime.ts +2388 -0
  410. package/src/providers/linkedin-web.ts +3305 -0
  411. package/src/providers/linkedin.ts +1885 -0
  412. package/src/providers/meta-bootstrap.ts +1043 -0
  413. package/src/providers/meta-facebook-group.ts +690 -0
  414. package/src/providers/meta-marketplace-relay.ts +747 -0
  415. package/src/providers/meta-omni.ts +373 -0
  416. package/src/providers/meta-relay-bundle.ts +1049 -0
  417. package/src/providers/meta-web-descriptors.ts +1740 -0
  418. package/src/providers/meta-web-runtime.ts +3388 -0
  419. package/src/providers/meta-web.ts +2931 -0
  420. package/src/providers/read-failure.ts +139 -0
  421. package/src/providers/reddit-omni.ts +462 -0
  422. package/src/providers/reddit-web-runtime.ts +1929 -0
  423. package/src/providers/reddit-web.ts +1611 -0
  424. package/src/providers/rental-listings.ts +460 -0
  425. package/src/providers/substack-omni.ts +406 -0
  426. package/src/providers/substack-video-mp4.ts +33 -0
  427. package/src/providers/substack-web-runtime.ts +2812 -0
  428. package/src/providers/substack-web.ts +1502 -0
  429. package/src/providers/tiktok-video-mp4.ts +33 -0
  430. package/src/providers/tiktok-web-runtime.ts +770 -0
  431. package/src/providers/tiktok-web.ts +2786 -0
  432. package/src/providers/twitch-web-runtime.ts +226 -0
  433. package/src/providers/twitch-web.ts +274 -0
  434. package/src/providers/whatsapp-account-identity.ts +55 -0
  435. package/src/providers/whatsapp-contact-projection-helper.ts +874 -0
  436. package/src/providers/whatsapp-contact-projection-protocol.ts +403 -0
  437. package/src/providers/whatsapp-interaction-projection-helper.ts +1452 -0
  438. package/src/providers/whatsapp-interaction-projection-protocol.ts +462 -0
  439. package/src/providers/whatsapp-message-export-projection-protocol.ts +532 -0
  440. package/src/providers/whatsapp-messaging.ts +132 -0
  441. package/src/providers/whatsapp-omni.ts +586 -0
  442. package/src/providers/whatsapp-web-runtime.ts +3108 -0
  443. package/src/providers/whatsapp-web.ts +1037 -0
  444. package/src/providers/x-image-provenance.ts +523 -0
  445. package/src/providers/x-made-with-ai.ts +106 -0
  446. package/src/providers/x-transaction-id.ts +413 -0
  447. package/src/providers/x-web-runtime.ts +3485 -0
  448. package/src/providers/x-web.ts +2332 -0
  449. package/src/providers/x.ts +1889 -0
  450. package/src/providers/youtube-web-runtime.ts +1400 -0
  451. package/src/providers/youtube-web.ts +1221 -0
  452. package/src/read-admission-runtime.ts +91 -0
  453. package/src/read-client.ts +521 -0
  454. package/src/read-effect-platform.ts +7 -0
  455. package/src/read-effect-runtime.ts +14 -0
  456. package/src/read-effect.ts +52 -0
  457. package/src/read-projection-admission.ts +866 -0
  458. package/src/read-projections.ts +3151 -0
  459. package/src/recovery.ts +1492 -0
  460. package/src/reviewed-template.ts +146 -0
  461. package/src/run-journal.ts +1272 -0
  462. package/src/runtime.ts +5116 -0
  463. package/src/scripts/generate-provider-plugin-catalog.ts +121 -0
  464. package/src/scripts/install-whatsapp-protocol.sh +232 -0
  465. package/src/scripts/recover-install-stages.ts +1491 -0
  466. package/src/scripts/resolve-state-home.ts +30 -0
  467. package/src/scripts/scaffold-web-provider.ts +973 -0
  468. package/src/scripts/sync-bundled-adapters.ts +695 -0
  469. package/src/session-secrets.ts +1183 -0
  470. package/src/state-helper.bunfig.toml +2 -0
  471. package/src/state-helper.ts +2914 -0
  472. package/src/storage.ts +3842 -0
  473. package/src/transport-policy.ts +2 -0
  474. package/src/usage.ts +182 -0
  475. package/src/version.ts +2 -0
  476. package/src/web-session-authentication-policy.ts +136 -0
  477. package/src/web-session-cleanup-admission.ts +2344 -0
  478. package/src/web-session-cleanup-join.ts +99 -0
  479. package/src/web-session-client.ts +996 -0
  480. package/src/web-session-contract-definitions.ts +820 -0
  481. package/src/web-session-contract-planning.ts +42 -0
  482. package/src/web-session-contracts.ts +228 -0
  483. package/src/web-session-cookies.ts +500 -0
  484. package/src/web-session-execution.ts +385 -0
  485. package/src/web-session-http.ts +373 -0
  486. package/src/web-session-read-errors.ts +19 -0
  487. package/src/web-session-read-runtime.ts +124 -0
  488. package/src/web-session-recovery.ts +818 -0
  489. package/src/web-session-sites.ts +58 -0
  490. package/src/web-session-template.ts +994 -0
  491. package/src/web-session.ts +93 -0
  492. package/src/whatsapp-client-binding.ts +21 -0
  493. package/src/whatsapp-client-types.ts +75 -0
  494. package/src/whatsapp-client.ts +395 -0
  495. package/src/whatsapp-export-coordinate.ts +17 -0
  496. package/src/whatsapp-message-bundle-v2.ts +195 -0
  497. package/src/whatsapp-message-like-me-cli.ts +195 -0
  498. package/src/whatsapp-message-like-me-export.ts +1072 -0
  499. package/src/whatsapp-message-like-me-source.ts +1012 -0
  500. package/tsconfig.json +67 -0
@@ -0,0 +1,1889 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import {
3
+ closeSync,
4
+ constants,
5
+ fstatSync,
6
+ openSync,
7
+ readSync,
8
+ type BigIntStats,
9
+ } from "node:fs";
10
+ import { isAbsolute } from "node:path";
11
+
12
+ import { isXAccountSubject } from "../provider-subject";
13
+ import { bearerHeaders, type ProviderResponse } from "../provider-http";
14
+ import type { ProviderActionContext, ProviderFile } from "../provider-context";
15
+ import { rejectGifProvenanceMarkers, scrubXUploadImage } from "./x-image-provenance";
16
+ import { rejectXTweetMadeWithAiLabel } from "./x-made-with-ai";
17
+
18
+ const X_API_ORIGIN = "https://api.x.com";
19
+ const X_ALLOWED_HOSTS = ["api.x.com"] as const;
20
+
21
+ const POST_FIELDS = [
22
+ "article",
23
+ "attachments",
24
+ "author_id",
25
+ "card_uri",
26
+ "community_id",
27
+ "context_annotations",
28
+ "conversation_id",
29
+ "created_at",
30
+ "display_text_range",
31
+ "edit_controls",
32
+ "edit_history_tweet_ids",
33
+ "entities",
34
+ "geo",
35
+ "id",
36
+ "in_reply_to_user_id",
37
+ "lang",
38
+ "matched_media_notes",
39
+ "media_metadata",
40
+ "note_tweet",
41
+ "paid_partnership",
42
+ "possibly_sensitive",
43
+ "public_metrics",
44
+ "referenced_tweets",
45
+ "reply_settings",
46
+ "scopes",
47
+ "source",
48
+ "suggested_source_links",
49
+ "suggested_source_links_with_counts",
50
+ "text",
51
+ "note_request_suggestions",
52
+ "withheld",
53
+ ].join(",");
54
+
55
+ const POST_EXPANSIONS = [
56
+ "article.cover_media",
57
+ "article.media_entities",
58
+ "attachments.media_keys",
59
+ "attachments.media_source_tweet",
60
+ "attachments.poll_ids",
61
+ "author_id",
62
+ "edit_history_tweet_ids",
63
+ "entities.mentions.username",
64
+ "entities.note.mentions.username",
65
+ "geo.place_id",
66
+ "in_reply_to_user_id",
67
+ "referenced_tweets.id",
68
+ "referenced_tweets.id.attachments.media_keys",
69
+ "referenced_tweets.id.author_id",
70
+ ].join(",");
71
+
72
+ const MEDIA_FIELDS = [
73
+ "alt_text",
74
+ "duration_ms",
75
+ "height",
76
+ "media_key",
77
+ "preview_image_url",
78
+ "public_metrics",
79
+ "type",
80
+ "url",
81
+ "variants",
82
+ "width",
83
+ ].join(",");
84
+
85
+ const POLL_FIELDS = "duration_minutes,end_datetime,id,options,voting_status";
86
+ const PLACE_FIELDS = "contained_within,country,country_code,full_name,geo,id,name,place_type";
87
+ const USER_FIELDS = [
88
+ "affiliation",
89
+ "created_at",
90
+ "description",
91
+ "entities",
92
+ "id",
93
+ "is_identity_verified",
94
+ "location",
95
+ "most_recent_tweet_id",
96
+ "name",
97
+ "parody",
98
+ "pinned_tweet_id",
99
+ "profile_banner_url",
100
+ "profile_image_url",
101
+ "protected",
102
+ "public_metrics",
103
+ "receives_your_dm",
104
+ "subscription_type",
105
+ "subscription",
106
+ "url",
107
+ "username",
108
+ "verified",
109
+ "verified_followers_count",
110
+ "verified_type",
111
+ "withheld",
112
+ ].join(",");
113
+
114
+ const DM_EVENT_FIELDS = [
115
+ "attachments",
116
+ "created_at",
117
+ "dm_conversation_id",
118
+ "entities",
119
+ "event_type",
120
+ "id",
121
+ "participant_ids",
122
+ "referenced_tweets",
123
+ "sender_id",
124
+ "text",
125
+ ].join(",");
126
+
127
+ const DM_EXPANSIONS = [
128
+ "attachments.media_keys",
129
+ "participant_ids",
130
+ "referenced_tweets.id",
131
+ "sender_id",
132
+ ].join(",");
133
+
134
+ const CHAT_CONVERSATION_FIELDS = [
135
+ "admin_ids",
136
+ "created_at",
137
+ "group_avatar_url",
138
+ "group_name",
139
+ "id",
140
+ "is_muted",
141
+ "member_ids",
142
+ "message_ttl_msec",
143
+ "participant_ids",
144
+ "screen_capture_blocking_enabled",
145
+ "screen_capture_detection_enabled",
146
+ "type",
147
+ "updated_at",
148
+ ].join(",");
149
+
150
+ const CHAT_CONVERSATION_EXPANSIONS = "admin_ids,member_ids,participant_ids";
151
+ const CHAT_MESSAGE_EVENT_FIELDS = [
152
+ "conversation_id",
153
+ "conversation_token",
154
+ "created_at_msec",
155
+ "encoded_event",
156
+ "id",
157
+ "is_trusted",
158
+ "message_event_signature",
159
+ "previous_id",
160
+ "sender_id",
161
+ ].join(",");
162
+
163
+ const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
164
+ const MAX_GIF_BYTES = 15 * 1024 * 1024;
165
+ const MAX_VIDEO_BYTES = 512 * 1024 * 1024;
166
+ const MEDIA_CHUNK_BYTES = 4 * 1024 * 1024;
167
+ const FILE_VERIFY_CHUNK_BYTES = 1024 * 1024;
168
+ const MAX_MEDIA_STATUS_POLLS = 100;
169
+ const MAX_MEDIA_STATUS_WAIT_MS = 8 * 60_000;
170
+ const MAX_CHAT_KEY_EVENTS = 1_000;
171
+ const MAX_CHAT_ENVELOPE_CHARACTERS = 2 * 1024 * 1024;
172
+
173
+ type JsonRecord = Record<string, unknown>;
174
+
175
+ type UploadedMedia = {
176
+ readonly id: string;
177
+ readonly category: string;
178
+ readonly mediaType: string;
179
+ readonly bytes: number;
180
+ readonly altText?: string;
181
+ };
182
+
183
+ type PreparedProviderFile = {
184
+ readonly file: ProviderFile;
185
+ /** The exact file identity observed by the complete pre-dispatch digest pass. */
186
+ readonly preflightIdentity: BigIntStats;
187
+ };
188
+
189
+ type OpenedProviderFile = {
190
+ readonly descriptor: number;
191
+ readonly stats: BigIntStats;
192
+ };
193
+
194
+ function isRecord(value: unknown): value is JsonRecord {
195
+ return typeof value === "object" && value !== null && !Array.isArray(value);
196
+ }
197
+
198
+ function record(value: unknown, label: string): JsonRecord {
199
+ if (!isRecord(value)) throw new Error(`official X ${label} is not an object`);
200
+ return value;
201
+ }
202
+
203
+ function recordBody(response: ProviderResponse, label: string): JsonRecord {
204
+ if (!isRecord(response.body)) throw new Error(`official X ${label} response is not an object`);
205
+ return response.body;
206
+ }
207
+
208
+ function dataRecord(response: ProviderResponse, label: string): JsonRecord {
209
+ const body = recordBody(response, label);
210
+ if (body.errors !== undefined) {
211
+ if (!Array.isArray(body.errors)) throw new Error(`official X ${label} response returned invalid errors`);
212
+ if (body.errors.length > 0) throw new Error(`official X ${label} response returned provider errors`);
213
+ }
214
+ if (!isRecord(body.data)) throw new Error(`official X ${label} response omitted data`);
215
+ return body.data;
216
+ }
217
+
218
+ function requiredString(record: JsonRecord, key: string, label: string): string {
219
+ const value = record[key];
220
+ if (typeof value !== "string" || value.length === 0) {
221
+ throw new Error(`official X ${label} response omitted ${key}`);
222
+ }
223
+ return value;
224
+ }
225
+
226
+ function optionalString(record: JsonRecord | null, key: string): string | null {
227
+ const value = record?.[key];
228
+ return typeof value === "string" && value.length > 0 ? value : null;
229
+ }
230
+
231
+ function responseItems(response: ProviderResponse, label: string): readonly unknown[] {
232
+ const body = recordBody(response, label);
233
+ if (body.data === undefined) return [];
234
+ if (!Array.isArray(body.data)) throw new Error(`official X ${label} response data is not a collection`);
235
+ return body.data;
236
+ }
237
+
238
+ function inputString(context: ProviderActionContext, name: string): string {
239
+ const value = context.input[name];
240
+ if (typeof value !== "string") throw new Error(`input.${name} must be a string`);
241
+ return value;
242
+ }
243
+
244
+ function optionalInputString(context: ProviderActionContext, name: string): string | undefined {
245
+ const value = context.input[name];
246
+ if (value === undefined) return undefined;
247
+ if (typeof value !== "string") throw new Error(`input.${name} must be a string`);
248
+ return value;
249
+ }
250
+
251
+ function optionalInputNumber(context: ProviderActionContext, name: string): number | undefined {
252
+ const value = context.input[name];
253
+ if (value === undefined) return undefined;
254
+ if (typeof value !== "number" || !Number.isSafeInteger(value)) throw new Error(`input.${name} must be a safe integer`);
255
+ return value;
256
+ }
257
+
258
+ function optionalInputBoolean(context: ProviderActionContext, name: string): boolean | undefined {
259
+ const value = context.input[name];
260
+ if (value === undefined) return undefined;
261
+ if (typeof value !== "boolean") throw new Error(`input.${name} must be a boolean`);
262
+ return value;
263
+ }
264
+
265
+ /** Preserve the provider's explicitly authorized true value and omit false or unset. */
266
+ function officialMadeWithAiLabel(madeWithAi: boolean | undefined): true | undefined {
267
+ return madeWithAi === true ? true : undefined;
268
+ }
269
+
270
+ function inputStrings(context: ProviderActionContext, name: string): readonly string[] {
271
+ const value = context.input[name];
272
+ if (!Array.isArray(value) || !value.every((item) => typeof item === "string")) {
273
+ throw new Error(`input.${name} must be a string array`);
274
+ }
275
+ return value;
276
+ }
277
+
278
+ function optionalInputStrings(context: ProviderActionContext, name: string): readonly string[] | undefined {
279
+ if (context.input[name] === undefined) return undefined;
280
+ return inputStrings(context, name);
281
+ }
282
+
283
+ function inputNumbers(context: ProviderActionContext, name: string): readonly number[] {
284
+ const value = context.input[name];
285
+ if (!Array.isArray(value)) throw new Error(`input.${name} must be a safe-integer array`);
286
+ const numbers: number[] = [];
287
+ for (const item of value) {
288
+ if (typeof item !== "number" || !Number.isSafeInteger(item)) {
289
+ throw new Error(`input.${name} must be a safe-integer array`);
290
+ }
291
+ numbers.push(item);
292
+ }
293
+ return numbers;
294
+ }
295
+
296
+ function hasAnyAsciiControl(value: string): boolean {
297
+ for (let index = 0; index < value.length; index += 1) {
298
+ const code = value.charCodeAt(index);
299
+ if (code <= 0x1f || code === 0x7f) return true;
300
+ }
301
+ return false;
302
+ }
303
+
304
+ function exactXId(value: string, label: string): string {
305
+ if (!isXAccountSubject(value)) throw new Error(`${label} must be a 1-19 digit X object ID`);
306
+ return value;
307
+ }
308
+
309
+ function inputXId(context: ProviderActionContext, name: string): string {
310
+ return exactXId(inputString(context, name), `input.${name}`);
311
+ }
312
+
313
+ function optionalInputXId(context: ProviderActionContext, name: string): string | undefined {
314
+ const value = optionalInputString(context, name);
315
+ return value === undefined ? undefined : exactXId(value, `input.${name}`);
316
+ }
317
+
318
+ function exactDmConversationId(value: string, label: string): string {
319
+ if (!/^(?:[0-9]{15,19}|[0-9]{1,19}-[0-9]{1,19})$/u.test(value)) {
320
+ throw new Error(`${label} must be an exact legacy X DM conversation ID`);
321
+ }
322
+ if (value.includes("-")) {
323
+ const [left, right] = value.split("-");
324
+ if (left === right) throw new Error(`${label} must identify two different legacy X DM participants`);
325
+ }
326
+ return value;
327
+ }
328
+
329
+ function exactChatConversationId(value: string, label: string): string {
330
+ if (!/^(?:[0-9]{1,19}|[0-9]{1,19}-[0-9]{1,19}|g[0-9]{1,19})$/u.test(value)) {
331
+ throw new Error(`${label} must be an exact X Chat recipient or conversation ID`);
332
+ }
333
+ if (value.includes("-")) {
334
+ const [left, right] = value.split("-");
335
+ if (left === right) throw new Error(`${label} must identify two different X Chat participants`);
336
+ }
337
+ return value;
338
+ }
339
+
340
+ function exactChatEventConversationId(value: string, label: string): string {
341
+ if (!/^(?:[0-9]{1,19}:[0-9]{1,19}|g[0-9]{1,19})$/u.test(value)) {
342
+ throw new Error(`${label} must be an exact X Chat event conversation ID`);
343
+ }
344
+ return value;
345
+ }
346
+
347
+ function exactEncryptedChatEnvelope(value: unknown, label: string): string {
348
+ if (
349
+ typeof value !== "string"
350
+ || value.length === 0
351
+ || value.length > MAX_CHAT_ENVELOPE_CHARACTERS
352
+ || value.length % 4 !== 0
353
+ || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u.test(value)
354
+ || Buffer.from(value, "base64").toString("base64") !== value
355
+ ) {
356
+ throw new Error(`official X ${label} contained an invalid encrypted envelope`);
357
+ }
358
+ return value;
359
+ }
360
+
361
+ function exactUniqueXIds(values: readonly string[], label: string): readonly string[] {
362
+ const ids = values.map((value, index) => exactXId(value, `${label}[${index}]`));
363
+ if (new Set(ids).size !== ids.length) throw new Error(`${label} must contain unique X object IDs`);
364
+ return ids;
365
+ }
366
+
367
+ function bearer(context: ProviderActionContext, json = false): Headers {
368
+ return bearerHeaders(context.token.accessToken, {
369
+ Accept: "application/json",
370
+ ...(json ? { "Content-Type": "application/json" } : {}),
371
+ });
372
+ }
373
+
374
+ function encodedSegment(value: string): string {
375
+ return encodeURIComponent(value);
376
+ }
377
+
378
+ function richPostParams(url: URL): void {
379
+ url.searchParams.set("tweet.fields", POST_FIELDS);
380
+ url.searchParams.set("expansions", POST_EXPANSIONS);
381
+ url.searchParams.set("media.fields", MEDIA_FIELDS);
382
+ url.searchParams.set("poll.fields", POLL_FIELDS);
383
+ url.searchParams.set("place.fields", PLACE_FIELDS);
384
+ url.searchParams.set("user.fields", USER_FIELDS);
385
+ }
386
+
387
+ function richDmParams(url: URL): void {
388
+ url.searchParams.set("dm_event.fields", DM_EVENT_FIELDS);
389
+ url.searchParams.set("expansions", DM_EXPANSIONS);
390
+ url.searchParams.set("media.fields", MEDIA_FIELDS);
391
+ url.searchParams.set("tweet.fields", POST_FIELDS);
392
+ url.searchParams.set("user.fields", USER_FIELDS);
393
+ }
394
+
395
+ function richChatConversationParams(url: URL): void {
396
+ url.searchParams.set("chat_conversation.fields", CHAT_CONVERSATION_FIELDS);
397
+ url.searchParams.set("expansions", CHAT_CONVERSATION_EXPANSIONS);
398
+ url.searchParams.set("user.fields", USER_FIELDS);
399
+ }
400
+
401
+ async function request(
402
+ context: ProviderActionContext,
403
+ url: URL | string,
404
+ init: RequestInit,
405
+ statuses: readonly number[],
406
+ ): Promise<ProviderResponse> {
407
+ const response = await context.http.request(url, init, statuses, X_ALLOWED_HOSTS);
408
+ const method = (init.method ?? "GET").toUpperCase();
409
+ if (method !== "GET" && method !== "HEAD") {
410
+ const body = response.body;
411
+ if (isRecord(body) && body.errors !== undefined) {
412
+ if (!Array.isArray(body.errors)) throw new Error("official X mutation response returned invalid errors");
413
+ if (body.errors.length > 0) throw new Error("official X mutation response returned provider errors");
414
+ }
415
+ }
416
+ return response;
417
+ }
418
+
419
+ async function authenticatedUserId(context: ProviderActionContext): Promise<string> {
420
+ const url = new URL(`${X_API_ORIGIN}/2/users/me`);
421
+ url.searchParams.set("user.fields", "id,name,username");
422
+ const response = await request(context, url, { method: "GET", headers: bearer(context) }, [200]);
423
+ const id = exactXId(
424
+ requiredString(dataRecord(response, "authenticated-user"), "id", "authenticated-user"),
425
+ "official X authenticated-user id",
426
+ );
427
+ if (context.auth.subject !== undefined && context.auth.subject !== id) {
428
+ throw new Error("authenticated X user does not match the OAuth locator subject");
429
+ }
430
+ return id;
431
+ }
432
+
433
+ async function authenticatedWriteUserId(context: ProviderActionContext): Promise<string> {
434
+ if (context.auth.subject === undefined) {
435
+ throw new Error("official X write capabilities require an OAuth locator with the exact authenticated user ID as its subject");
436
+ }
437
+ exactXId(context.auth.subject, "OAuth locator subject");
438
+ return authenticatedUserId(context);
439
+ }
440
+
441
+ async function authenticatedPrivateReadUserId(context: ProviderActionContext): Promise<string> {
442
+ if (context.auth.subject === undefined) {
443
+ throw new Error("official X private-message reads require an OAuth locator with the exact authenticated user ID as its subject");
444
+ }
445
+ exactXId(context.auth.subject, "OAuth locator subject");
446
+ return authenticatedUserId(context);
447
+ }
448
+
449
+ function providerPage(
450
+ response: ProviderResponse,
451
+ requestedLimit: number,
452
+ returned: number,
453
+ providerReturned: number,
454
+ ): JsonRecord {
455
+ const body = recordBody(response, "page");
456
+ if (body.meta !== undefined && !isRecord(body.meta)) throw new Error("official X page returned invalid metadata");
457
+ const meta = isRecord(body.meta) ? body.meta : null;
458
+ for (const name of ["next_token", "previous_token"] as const) {
459
+ const value = meta?.[name];
460
+ if (value !== undefined && (typeof value !== "string" || value.length === 0)) {
461
+ throw new Error(`official X page returned an invalid ${name}`);
462
+ }
463
+ }
464
+ const resultCount = meta?.result_count;
465
+ if (resultCount !== undefined && (!Number.isSafeInteger(resultCount) || resultCount !== providerReturned)) {
466
+ throw new Error("official X page result_count did not match the returned collection");
467
+ }
468
+ const next = optionalString(meta, "next_token");
469
+ const previous = optionalString(meta, "previous_token");
470
+ return {
471
+ requestedLimit,
472
+ returned,
473
+ providerReturned,
474
+ cursor: {
475
+ next,
476
+ previous,
477
+ },
478
+ newestId: optionalString(meta, "newest_id"),
479
+ oldestId: optionalString(meta, "oldest_id"),
480
+ pageExhausted: meta === null ? null : next === null,
481
+ };
482
+ }
483
+
484
+ function exactDirectDmConversationSet(value: string, label: string): ReadonlySet<string> {
485
+ const id = exactDmConversationId(value, label);
486
+ if (!id.includes("-")) throw new Error(`${label} did not identify a direct legacy X DM conversation`);
487
+ return new Set(id.split("-"));
488
+ }
489
+
490
+ function sameIdSet(left: ReadonlySet<string>, right: ReadonlySet<string>): boolean {
491
+ return left.size === right.size && [...left].every((id) => right.has(id));
492
+ }
493
+
494
+ function chatProviderPage(
495
+ response: ProviderResponse,
496
+ requestedLimit: number,
497
+ returned: number,
498
+ ): JsonRecord {
499
+ const body = recordBody(response, "Chat page");
500
+ if (body.meta !== undefined && !isRecord(body.meta)) throw new Error("official X Chat page returned invalid metadata");
501
+ const metaPresent = isRecord(body.meta);
502
+ const meta = metaPresent ? body.meta as JsonRecord : {};
503
+ const nextValue = meta.next_token;
504
+ if (nextValue !== undefined && (typeof nextValue !== "string" || nextValue.length === 0)) {
505
+ throw new Error("official X Chat page returned an invalid next token");
506
+ }
507
+ const next = typeof nextValue === "string" ? nextValue : null;
508
+ const hasMoreValue = meta.has_more;
509
+ if (hasMoreValue !== undefined && typeof hasMoreValue !== "boolean") {
510
+ throw new Error("official X Chat page returned an invalid has_more value");
511
+ }
512
+ if (hasMoreValue === true && next === null) {
513
+ throw new Error("official X Chat page claimed more results without a next token");
514
+ }
515
+ if (hasMoreValue === false && next !== null) {
516
+ throw new Error("official X Chat page returned a next token after declaring the page complete");
517
+ }
518
+ const resultCount = meta.result_count;
519
+ if (resultCount !== undefined && (!Number.isSafeInteger(resultCount) || resultCount !== returned)) {
520
+ throw new Error("official X Chat page result_count did not match the returned collection");
521
+ }
522
+ return {
523
+ requestedLimit,
524
+ returned,
525
+ providerReturned: returned,
526
+ cursor: { next, previous: null },
527
+ pageExhausted: !metaPresent ? null : hasMoreValue === undefined ? next === null : !hasMoreValue,
528
+ };
529
+ }
530
+
531
+ function providerIncludes(response: ProviderResponse): JsonRecord {
532
+ const body = recordBody(response, "includes");
533
+ return isRecord(body.includes) ? body.includes : {};
534
+ }
535
+
536
+ function providerErrors(response: ProviderResponse): readonly unknown[] {
537
+ const body = recordBody(response, "errors");
538
+ return Array.isArray(body.errors) ? body.errors : [];
539
+ }
540
+
541
+ function exactItemIds(items: readonly unknown[], label: string): readonly string[] {
542
+ const ids = items.map((value, index) => {
543
+ const item = record(value, `${label} item ${index + 1}`);
544
+ return exactXId(requiredString(item, "id", `${label} item`), `official X ${label} item id`);
545
+ });
546
+ if (new Set(ids).size !== ids.length) throw new Error(`official X ${label} response contained duplicate item IDs`);
547
+ return ids;
548
+ }
549
+
550
+ function postUrl(id: string): string {
551
+ return `https://x.com/i/web/status/${encodedSegment(id)}`;
552
+ }
553
+
554
+ async function executeFeedsRead(context: ProviderActionContext): Promise<void> {
555
+ const feed = inputString(context, "feed");
556
+ const requestedLimit = optionalInputNumber(context, "limit") ?? 10;
557
+ let minimumResults = 1;
558
+ let url: URL;
559
+ let limitation: string;
560
+
561
+ if (feed === "home-reverse-chronological") {
562
+ const me = await authenticatedUserId(context);
563
+ url = new URL(`${X_API_ORIGIN}/2/users/${encodedSegment(me)}/timelines/reverse_chronological`);
564
+ limitation = "Reverse chronological followed-account timeline; this is not the algorithmic For You feed.";
565
+ } else if (feed === "user") {
566
+ minimumResults = 5;
567
+ url = new URL(`${X_API_ORIGIN}/2/users/${encodedSegment(inputXId(context, "user_id"))}/tweets`);
568
+ limitation = "The official endpoint is bounded to the account's recent post history and may omit older posts.";
569
+ } else if (feed === "mentions") {
570
+ minimumResults = 5;
571
+ url = new URL(`${X_API_ORIGIN}/2/users/${encodedSegment(inputXId(context, "user_id"))}/mentions`);
572
+ limitation = "The official mentions endpoint is bounded to recent results and may omit older mentions.";
573
+ } else if (feed === "list") {
574
+ context.addRequiredScopes(["list.read"]);
575
+ url = new URL(`${X_API_ORIGIN}/2/lists/${encodedSegment(inputXId(context, "list_id"))}/tweets`);
576
+ limitation = "This is the official List-post collection, not a home or For You feed.";
577
+ } else if (feed === "recent-search") {
578
+ minimumResults = 10;
579
+ url = new URL(`${X_API_ORIGIN}/2/tweets/search/recent`);
580
+ url.searchParams.set("query", inputString(context, "query"));
581
+ limitation = "Recent search covers at most the last seven days; older matching posts are not represented.";
582
+ } else if (feed === "bookmarks") {
583
+ context.addRequiredScopes(["bookmark.read"]);
584
+ const me = await authenticatedUserId(context);
585
+ url = new URL(`${X_API_ORIGIN}/2/users/${encodedSegment(me)}/bookmarks`);
586
+ limitation = "This is the authenticated account's official bookmark collection, not a public feed.";
587
+ } else {
588
+ throw new Error("input.feed names an unsupported official X feed");
589
+ }
590
+
591
+ if (requestedLimit < minimumResults) {
592
+ throw new Error(`input.limit must be at least ${minimumResults} for the official X ${feed} endpoint`);
593
+ }
594
+
595
+ url.searchParams.set("max_results", String(requestedLimit));
596
+ const cursor = optionalInputString(context, "cursor");
597
+ if (cursor !== undefined) {
598
+ url.searchParams.set(feed === "recent-search" ? "next_token" : "pagination_token", cursor);
599
+ }
600
+
601
+ if (["home-reverse-chronological", "user", "mentions", "recent-search"].includes(feed)) {
602
+ for (const [inputName, queryName] of [
603
+ ["since_id", "since_id"],
604
+ ["until_id", "until_id"],
605
+ ["start_time", "start_time"],
606
+ ["end_time", "end_time"],
607
+ ] as const) {
608
+ const value = inputName === "since_id" || inputName === "until_id"
609
+ ? optionalInputXId(context, inputName)
610
+ : optionalInputString(context, inputName);
611
+ if (value !== undefined) url.searchParams.set(queryName, value);
612
+ }
613
+ }
614
+
615
+ if (feed === "home-reverse-chronological" || feed === "user") {
616
+ const exclusions: string[] = [];
617
+ if (optionalInputBoolean(context, "exclude_replies") === true) exclusions.push("replies");
618
+ if (optionalInputBoolean(context, "exclude_reposts") === true) exclusions.push("retweets");
619
+ if (exclusions.length > 0) url.searchParams.set("exclude", exclusions.join(","));
620
+ }
621
+ if (feed === "recent-search") {
622
+ const sort = optionalInputString(context, "sort");
623
+ if (sort !== undefined) url.searchParams.set("sort_order", sort);
624
+ }
625
+ richPostParams(url);
626
+
627
+ const response = await request(context, url, { method: "GET", headers: bearer(context) }, [200]);
628
+ const allItems = responseItems(response, "feed");
629
+ if (allItems.length > requestedLimit) {
630
+ throw new Error("official X feed response exceeded the requested result bound");
631
+ }
632
+ const items = allItems;
633
+ exactItemIds(items, "feed");
634
+ context.setOutput({
635
+ provider: "x",
636
+ operation: "feeds.read",
637
+ feed,
638
+ items,
639
+ includes: providerIncludes(response),
640
+ providerErrors: providerErrors(response),
641
+ page: providerPage(response, requestedLimit, items.length, allItems.length),
642
+ coverage: {
643
+ complete: false,
644
+ limitation,
645
+ forYou: false,
646
+ },
647
+ });
648
+ }
649
+
650
+ async function executePostsRead(context: ProviderActionContext): Promise<void> {
651
+ const ids = exactUniqueXIds(inputStrings(context, "post_ids"), "input.post_ids");
652
+ const url = new URL(`${X_API_ORIGIN}/2/tweets`);
653
+ url.searchParams.set("ids", ids.join(","));
654
+ richPostParams(url);
655
+ const response = await request(context, url, { method: "GET", headers: bearer(context) }, [200]);
656
+ const items = responseItems(response, "post lookup");
657
+ const returnedIds = exactItemIds(items, "post lookup");
658
+ const requested = new Set(ids);
659
+ if (returnedIds.some((id) => !requested.has(id))) {
660
+ throw new Error("official X post lookup returned an unrequested post ID");
661
+ }
662
+ const errors = providerErrors(response);
663
+ context.setOutput({
664
+ provider: "x",
665
+ operation: "posts.read",
666
+ items,
667
+ includes: providerIncludes(response),
668
+ providerErrors: errors,
669
+ coverage: {
670
+ complete: returnedIds.length === ids.length && errors.length === 0,
671
+ requestedIds: ids,
672
+ returned: items.length,
673
+ },
674
+ });
675
+ if (ids.length === 1) context.setFinalUrl(postUrl(ids[0] ?? ""));
676
+ }
677
+
678
+ async function executeCommentsRead(context: ProviderActionContext): Promise<void> {
679
+ const postId = inputXId(context, "post_id");
680
+ const requestedLimit = optionalInputNumber(context, "limit") ?? 10;
681
+ if (requestedLimit < 10) throw new Error("input.limit must be at least 10 for official X recent reply search");
682
+ const window = optionalInputString(context, "window") ?? "recent-7-days";
683
+ const url = new URL(window === "recent-7-days"
684
+ ? `${X_API_ORIGIN}/2/tweets/search/recent`
685
+ : window === "full-archive"
686
+ ? `${X_API_ORIGIN}/2/tweets/search/all`
687
+ : (() => { throw new Error("input.window names an unsupported official X reply-search window"); })());
688
+ url.searchParams.set("query", `conversation_id:${postId} is:reply`);
689
+ url.searchParams.set("max_results", String(Math.max(10, requestedLimit)));
690
+ const cursor = optionalInputString(context, "cursor");
691
+ if (cursor !== undefined) url.searchParams.set("next_token", cursor);
692
+ richPostParams(url);
693
+ const response = await request(context, url, { method: "GET", headers: bearer(context) }, [200]);
694
+ const allItems = responseItems(response, "reply search");
695
+ if (allItems.length > requestedLimit) throw new Error("official X reply search exceeded the requested result bound");
696
+ const items = allItems;
697
+ exactItemIds(items, "reply search");
698
+ for (const [index, value] of items.entries()) {
699
+ const item = record(value, `reply search item ${index + 1}`);
700
+ if (exactXId(requiredString(item, "conversation_id", "reply search item"), "official X reply conversation id") !== postId) {
701
+ throw new Error("official X reply search returned an item outside the requested conversation");
702
+ }
703
+ }
704
+ context.setOutput({
705
+ provider: "x",
706
+ operation: "comments.read",
707
+ rootPostId: postId,
708
+ items,
709
+ includes: providerIncludes(response),
710
+ providerErrors: providerErrors(response),
711
+ page: providerPage(response, requestedLimit, items.length, allItems.length),
712
+ coverage: {
713
+ complete: false,
714
+ limitation: window === "recent-7-days"
715
+ ? "Replies are reconstructed with recent search, which covers at most seven days; this is never a complete conversation archive."
716
+ : "Replies are reconstructed with entitled full-archive search and opaque pagination; search results are never labeled a provably complete conversation archive.",
717
+ window,
718
+ },
719
+ });
720
+ context.setFinalUrl(postUrl(postId));
721
+ }
722
+
723
+ async function executeMessagingList(context: ProviderActionContext): Promise<void> {
724
+ const view = inputString(context, "view");
725
+ const requestedLimit = optionalInputNumber(context, "limit") ?? 100;
726
+ let url: URL;
727
+ let chatView = false;
728
+ if (view === "all") {
729
+ url = new URL(`${X_API_ORIGIN}/2/dm_events`);
730
+ } else if (view === "participant") {
731
+ const participantId = inputXId(context, "target_id");
732
+ if (participantId === context.auth.subject) throw new Error("input.target_id must identify another X DM participant");
733
+ url = new URL(`${X_API_ORIGIN}/2/dm_conversations/with/${encodedSegment(participantId)}/dm_events`);
734
+ } else if (view === "conversation") {
735
+ const conversationId = exactDmConversationId(inputString(context, "target_id"), "input.target_id");
736
+ url = new URL(`${X_API_ORIGIN}/2/dm_conversations/${encodedSegment(conversationId)}/dm_events`);
737
+ } else if (view === "chat-conversations") {
738
+ chatView = true;
739
+ url = new URL(`${X_API_ORIGIN}/2/chat/conversations`);
740
+ } else if (view === "chat-events") {
741
+ chatView = true;
742
+ const conversationId = exactChatConversationId(inputString(context, "target_id"), "input.target_id");
743
+ if (conversationId === context.auth.subject) {
744
+ throw new Error("input.target_id must identify another X Chat participant");
745
+ }
746
+ url = new URL(`${X_API_ORIGIN}/2/chat/conversations/${encodedSegment(conversationId)}/events`);
747
+ } else {
748
+ throw new Error("input.view names an unsupported official X messaging collection");
749
+ }
750
+ url.searchParams.set("max_results", String(requestedLimit));
751
+ const cursor = optionalInputString(context, "cursor");
752
+ if (cursor !== undefined) url.searchParams.set("pagination_token", cursor);
753
+ if (view === "chat-conversations") richChatConversationParams(url);
754
+ else if (view === "chat-events") url.searchParams.set("chat_message_event.fields", CHAT_MESSAGE_EVENT_FIELDS);
755
+ else richDmParams(url);
756
+ await authenticatedPrivateReadUserId(context);
757
+ const response = await request(context, url, { method: "GET", headers: bearer(context) }, [200]);
758
+ const label = view === "chat-conversations" ? "Chat conversation list" : view === "chat-events" ? "Chat event list" : "DM-event list";
759
+ const items = responseItems(response, label);
760
+ if (items.length > requestedLimit) throw new Error(`official X ${label} response exceeded the requested result bound`);
761
+ if (view === "chat-conversations") {
762
+ const ids = items.map((value, index) => {
763
+ const item = record(value, `Chat conversation item ${index + 1}`);
764
+ return exactChatConversationId(requiredString(item, "id", "Chat conversation item"), "official X Chat conversation id");
765
+ });
766
+ if (new Set(ids).size !== ids.length) throw new Error("official X Chat conversation response contained duplicate IDs");
767
+ } else if (view === "chat-events") {
768
+ const ids = items.map((value, index) => {
769
+ const item = record(value, `Chat event item ${index + 1}`);
770
+ const id = requiredString(item, "id", "Chat event item");
771
+ if (id.length > 256 || hasAnyAsciiControl(id)) {
772
+ throw new Error("official X Chat event response contained an invalid event ID");
773
+ }
774
+ return id;
775
+ });
776
+ if (new Set(ids).size !== ids.length) throw new Error("official X Chat event response contained duplicate IDs");
777
+ } else {
778
+ exactItemIds(items, label);
779
+ const subject = exactXId(context.auth.subject ?? "", "OAuth locator subject");
780
+ const requestedParticipant = view === "participant" ? inputXId(context, "target_id") : null;
781
+ const requestedConversation = view === "conversation"
782
+ ? exactDmConversationId(inputString(context, "target_id"), "input.target_id")
783
+ : null;
784
+ for (const [index, value] of items.entries()) {
785
+ const item = record(value, `DM-event item ${index + 1}`);
786
+ const conversationId = exactDmConversationId(
787
+ requiredString(item, "dm_conversation_id", "DM-event item"),
788
+ "official X DM-event conversation id",
789
+ );
790
+ if (requestedConversation !== null && conversationId !== requestedConversation) {
791
+ throw new Error("official X DM-event response contained an event outside the requested conversation");
792
+ }
793
+ if (requestedParticipant !== null) {
794
+ const returnedParticipants = exactDirectDmConversationSet(
795
+ conversationId,
796
+ "official X DM-event conversation id",
797
+ );
798
+ const requestedParticipants = new Set([subject, requestedParticipant]);
799
+ if (!sameIdSet(returnedParticipants, requestedParticipants)) {
800
+ throw new Error("official X DM-event response contained an event outside the requested participant conversation");
801
+ }
802
+ }
803
+ }
804
+ }
805
+ if (view === "chat-events") {
806
+ const target = exactChatConversationId(inputString(context, "target_id"), "input.target_id");
807
+ const subject = exactXId(context.auth.subject ?? "", "OAuth locator subject");
808
+ const dataEnvelopes = new Set<string>();
809
+ for (const [index, value] of items.entries()) {
810
+ const item = record(value, `Chat event item ${index + 1}`);
811
+ const encodedEvent = exactEncryptedChatEnvelope(item.encoded_event, "Chat event response");
812
+ if (dataEnvelopes.has(encodedEvent)) {
813
+ throw new Error("official X Chat event response contained duplicate encrypted envelopes");
814
+ }
815
+ dataEnvelopes.add(encodedEvent);
816
+ if (item.sender_id !== undefined) exactXId(requiredString(item, "sender_id", "Chat event item"), "official X Chat sender id");
817
+ if (item.is_trusted !== undefined && typeof item.is_trusted !== "boolean") {
818
+ throw new Error("official X Chat event response contained an invalid trust marker");
819
+ }
820
+ if (item.created_at_msec !== undefined
821
+ && (typeof item.created_at_msec !== "string" || !/^[0-9]{1,20}$/u.test(item.created_at_msec))) {
822
+ throw new Error("official X Chat event response contained an invalid creation timestamp");
823
+ }
824
+ const returned = exactChatEventConversationId(
825
+ requiredString(item, "conversation_id", "Chat event item"),
826
+ "official X Chat event conversation id",
827
+ );
828
+ const matches = target.startsWith("g")
829
+ ? returned === target
830
+ : (() => {
831
+ if (returned.startsWith("g")) return false;
832
+ const returnedUsers = new Set(returned.split(":"));
833
+ const targetUsers = new Set(target.includes("-") ? target.split("-") : [subject, target]);
834
+ if (targetUsers.size !== 2) throw new Error("input.target_id must identify another X Chat participant");
835
+ return returnedUsers.size === 2
836
+ && [...targetUsers].every((id) => returnedUsers.has(id))
837
+ && [...returnedUsers].every((id) => targetUsers.has(id));
838
+ })();
839
+ if (!matches) throw new Error("official X Chat event response contained an event outside the requested conversation");
840
+ }
841
+ const responseBody = recordBody(response, "Chat event list");
842
+ if (responseBody.meta !== undefined && !isRecord(responseBody.meta)) {
843
+ throw new Error("official X Chat page returned invalid metadata");
844
+ }
845
+ const keyEventsValue = isRecord(responseBody.meta) ? responseBody.meta.conversation_key_events : undefined;
846
+ if (keyEventsValue !== undefined) {
847
+ if (!Array.isArray(keyEventsValue) || keyEventsValue.length > MAX_CHAT_KEY_EVENTS) {
848
+ throw new Error("official X Chat event metadata returned an invalid conversation_key_events collection");
849
+ }
850
+ const keyEvents = new Set<string>();
851
+ let aggregateCharacters = 0;
852
+ for (const [index, value] of keyEventsValue.entries()) {
853
+ const envelope = exactEncryptedChatEnvelope(value, `Chat conversation key event ${index + 1}`);
854
+ aggregateCharacters += envelope.length;
855
+ if (aggregateCharacters > MAX_CHAT_ENVELOPE_CHARACTERS) {
856
+ throw new Error("official X Chat conversation key events exceeded the aggregate response bound");
857
+ }
858
+ if (keyEvents.has(envelope) || dataEnvelopes.has(envelope)) {
859
+ throw new Error("official X Chat conversation key events contained duplicate encrypted envelopes");
860
+ }
861
+ keyEvents.add(envelope);
862
+ }
863
+ }
864
+ }
865
+ const body = recordBody(response, label);
866
+ const meta = isRecord(body.meta) ? body.meta : {};
867
+ if (view === "chat-conversations" && meta.has_message_requests !== undefined
868
+ && typeof meta.has_message_requests !== "boolean") {
869
+ throw new Error("official X Chat conversation page returned an invalid message-request indicator");
870
+ }
871
+ context.setOutput({
872
+ provider: "x",
873
+ operation: "messaging.list",
874
+ view,
875
+ items,
876
+ includes: providerIncludes(response),
877
+ providerErrors: providerErrors(response),
878
+ page: chatView
879
+ ? chatProviderPage(response, requestedLimit, items.length)
880
+ : providerPage(response, requestedLimit, items.length, items.length),
881
+ ...(chatView ? { meta } : {}),
882
+ coverage: chatView
883
+ ? {
884
+ complete: false,
885
+ encryptedChat: true,
886
+ plaintextAvailable: false,
887
+ ...(view === "chat-conversations" && typeof meta.has_message_requests === "boolean"
888
+ ? { messageRequestsPending: meta.has_message_requests }
889
+ : {}),
890
+ limitation: view === "chat-conversations"
891
+ ? "The official Chat index exposes conversation metadata and whether requests are pending, but not separate Requests/Priority/Hidden folder collections or plaintext messages."
892
+ : "Official Chat events contain signed encrypted envelopes; ghostget returns the provider envelope without claiming to decrypt or verify its plaintext.",
893
+ }
894
+ : {
895
+ complete: false,
896
+ window: "dm-events-30-days",
897
+ limitation: "The legacy DM-event API exposes at most 30 days and does not enumerate the Requests, Priority, and Hidden inbox views.",
898
+ encryptedChat: false,
899
+ },
900
+ });
901
+ }
902
+
903
+ async function executeMessagingRead(context: ProviderActionContext): Promise<void> {
904
+ const eventId = inputXId(context, "event_id");
905
+ const url = new URL(`${X_API_ORIGIN}/2/dm_events/${encodedSegment(eventId)}`);
906
+ richDmParams(url);
907
+ await authenticatedPrivateReadUserId(context);
908
+ const response = await request(context, url, { method: "GET", headers: bearer(context) }, [200]);
909
+ const item = dataRecord(response, "DM-event lookup");
910
+ const returnedId = exactXId(requiredString(item, "id", "DM-event lookup"), "official X DM-event id");
911
+ if (returnedId !== eventId) throw new Error("official X DM-event lookup returned an unexpected event ID");
912
+ context.setOutput({
913
+ provider: "x",
914
+ operation: "messaging.read",
915
+ item,
916
+ includes: providerIncludes(response),
917
+ providerErrors: providerErrors(response),
918
+ coverage: {
919
+ complete: true,
920
+ window: "dm-events-30-days",
921
+ limitation: "This reads one official DM event, not an encrypted Chat message or an inbox-folder classification.",
922
+ encryptedChat: false,
923
+ },
924
+ });
925
+ }
926
+
927
+ function mediaLimit(mediaType: string): number {
928
+ if (mediaType === "image/jpeg" || mediaType === "image/png") return MAX_IMAGE_BYTES;
929
+ if (mediaType === "image/gif") return MAX_GIF_BYTES;
930
+ if (mediaType === "video/mp4") return MAX_VIDEO_BYTES;
931
+ throw new Error(`official X media upload does not support ${mediaType}`);
932
+ }
933
+
934
+ function validateMediaFile(file: ProviderFile): void {
935
+ const maximum = mediaLimit(file.mediaType);
936
+ if (!Number.isSafeInteger(file.bytes) || file.bytes < 1 || file.bytes > maximum) {
937
+ throw new Error(`official X ${file.mediaType} attachment exceeds its ${maximum}-byte limit`);
938
+ }
939
+ if (!/^[a-f0-9]{64}$/u.test(file.sha256)) throw new Error("official X attachment has an invalid confirmed digest");
940
+ }
941
+
942
+ function validatePostMedia(files: readonly ProviderFile[]): void {
943
+ for (const file of files) validateMediaFile(file);
944
+ if (files.length > 4) {
945
+ throw new Error("official X posts support at most four static images");
946
+ }
947
+ const nonImages = files.filter((file) => file.mediaType === "image/gif" || file.mediaType === "video/mp4");
948
+ if (nonImages.length > 0 && files.length !== 1) {
949
+ throw new Error("official X posts support either up to four static images or one GIF/video");
950
+ }
951
+ }
952
+
953
+ function sameProviderFile(left: BigIntStats, right: BigIntStats): boolean {
954
+ return left.dev === right.dev
955
+ && left.ino === right.ino
956
+ && left.size === right.size
957
+ && left.mtimeNs === right.mtimeNs
958
+ && left.ctimeNs === right.ctimeNs
959
+ && left.mode === right.mode;
960
+ }
961
+
962
+ function openProviderFile(file: ProviderFile, expectedIdentity?: BigIntStats): OpenedProviderFile {
963
+ validateMediaFile(file);
964
+ if (!isAbsolute(file.path)) throw new Error("official X attachment resolver returned a non-absolute path");
965
+ let descriptor: number;
966
+ try {
967
+ descriptor = openSync(
968
+ file.path,
969
+ constants.O_RDONLY
970
+ | ("O_NOFOLLOW" in constants ? constants.O_NOFOLLOW : 0)
971
+ | ("O_NONBLOCK" in constants ? constants.O_NONBLOCK : 0),
972
+ );
973
+ } catch {
974
+ // Never surface the local attachment path from a system error.
975
+ throw new Error("official X attachment could not be opened safely");
976
+ }
977
+ try {
978
+ const stats = fstatSync(descriptor, { bigint: true });
979
+ const uid = typeof process.getuid === "function" ? process.getuid() : undefined;
980
+ if (
981
+ !stats.isFile()
982
+ || stats.size !== BigInt(file.bytes)
983
+ || (uid !== undefined && stats.uid !== BigInt(uid))
984
+ || (process.platform !== "win32" && (stats.mode & 0o777n) !== 0o600n)
985
+ ) throw new Error("official X attachments must be current-user-owned mode-0600 regular files with their confirmed size");
986
+ if (expectedIdentity !== undefined && !sameProviderFile(expectedIdentity, stats)) {
987
+ throw new Error("official X attachment identity changed after its provider preflight");
988
+ }
989
+ return { descriptor, stats };
990
+ } catch (error) {
991
+ closeSync(descriptor);
992
+ throw error;
993
+ }
994
+ }
995
+
996
+ function closeProviderFile(descriptor: number): void {
997
+ try {
998
+ closeSync(descriptor);
999
+ } catch {
1000
+ throw new Error("official X attachment descriptor could not be closed safely");
1001
+ }
1002
+ }
1003
+
1004
+ function verifiedProviderRead(
1005
+ file: ProviderFile,
1006
+ expectedIdentity: BigIntStats | undefined,
1007
+ materialize: boolean,
1008
+ ): { readonly identity: BigIntStats; readonly bytes: Buffer | null } {
1009
+ const opened = openProviderFile(file, expectedIdentity);
1010
+ const output = materialize ? Buffer.alloc(file.bytes) : null;
1011
+ const scratch = materialize ? null : Buffer.alloc(Math.min(FILE_VERIFY_CHUNK_BYTES, file.bytes));
1012
+ const hash = createHash("sha256");
1013
+ let offset = 0;
1014
+ try {
1015
+ while (offset < file.bytes) {
1016
+ const destination = output ?? scratch;
1017
+ if (destination === null) throw new Error("official X attachment verification buffer is unavailable");
1018
+ const destinationOffset = output === null ? 0 : offset;
1019
+ const length = Math.min(
1020
+ output === null ? destination.byteLength : FILE_VERIFY_CHUNK_BYTES,
1021
+ file.bytes - offset,
1022
+ );
1023
+ const count = readSync(opened.descriptor, destination, destinationOffset, length, offset);
1024
+ if (count < 1) throw new Error("official X attachment ended before its confirmed byte length");
1025
+ hash.update(destination.subarray(destinationOffset, destinationOffset + count));
1026
+ offset += count;
1027
+ }
1028
+ const after = fstatSync(opened.descriptor, { bigint: true });
1029
+ if (!sameProviderFile(opened.stats, after)) {
1030
+ throw new Error("official X attachment identity changed while it was verified");
1031
+ }
1032
+ if (hash.digest("hex") !== file.sha256) {
1033
+ throw new Error("official X attachment no longer matches its confirmed digest");
1034
+ }
1035
+ return { identity: opened.stats, bytes: output };
1036
+ } finally {
1037
+ closeProviderFile(opened.descriptor);
1038
+ }
1039
+ }
1040
+
1041
+ function preflightProviderFile(file: ProviderFile): PreparedProviderFile {
1042
+ const verified = verifiedProviderRead(file, undefined, false);
1043
+ return { file, preflightIdentity: verified.identity };
1044
+ }
1045
+
1046
+ function preflightProviderFiles(files: readonly ProviderFile[]): readonly PreparedProviderFile[] {
1047
+ return files.map(preflightProviderFile);
1048
+ }
1049
+
1050
+ function mediaCategory(mediaType: string, destination: "tweet" | "dm"): string {
1051
+ if (mediaType === "image/jpeg" || mediaType === "image/png") return `${destination}_image`;
1052
+ if (mediaType === "image/gif") return `${destination}_gif`;
1053
+ if (mediaType === "video/mp4") return `${destination}_video`;
1054
+ throw new Error(`official X media upload does not support ${mediaType}`);
1055
+ }
1056
+
1057
+ function genericUploadName(mediaType: string): string {
1058
+ if (mediaType === "image/jpeg") return "upload.jpg";
1059
+ if (mediaType === "image/png") return "upload.png";
1060
+ if (mediaType === "image/gif") return "upload.gif";
1061
+ if (mediaType === "video/mp4") return "upload.mp4";
1062
+ return "upload.bin";
1063
+ }
1064
+
1065
+ type MultipartField = {
1066
+ readonly name: string;
1067
+ readonly value: string;
1068
+ };
1069
+
1070
+ type MultipartPayload = {
1071
+ readonly body: Uint8Array;
1072
+ readonly contentType: string;
1073
+ };
1074
+
1075
+ function exactMultipartToken(value: string, label: string): string {
1076
+ if (!/^[A-Za-z0-9_.-]{1,128}$/u.test(value)) {
1077
+ throw new Error(`official X ${label} is not a safe multipart token`);
1078
+ }
1079
+ return value;
1080
+ }
1081
+
1082
+ function exactMultipartMediaType(value: string): string {
1083
+ if (!/^[a-z0-9!#$&^_.+-]{1,64}\/[a-z0-9!#$&^_.+-]{1,64}$/u.test(value)) {
1084
+ throw new Error("official X upload media type is not a safe multipart value");
1085
+ }
1086
+ return value;
1087
+ }
1088
+
1089
+ function exactMultipartValue(value: string, label: string): string {
1090
+ if (
1091
+ Buffer.byteLength(value, "utf8") > 1_024
1092
+ || value.includes("\r")
1093
+ || value.includes("\n")
1094
+ || value.includes("\0")
1095
+ ) {
1096
+ throw new Error(`official X ${label} is not a safe multipart value`);
1097
+ }
1098
+ return value;
1099
+ }
1100
+
1101
+ /**
1102
+ * Serialize the small, fixed X multipart shape into owned bytes. The pinned
1103
+ * transport deliberately rejects opaque FormData streams because it cannot
1104
+ * prove their size, ownership, or lifetime before opening the authenticated
1105
+ * socket.
1106
+ */
1107
+ function xMultipartPayload(
1108
+ fields: readonly MultipartField[],
1109
+ file: {
1110
+ readonly bytes: Uint8Array;
1111
+ readonly mediaType: string;
1112
+ readonly filename: string;
1113
+ },
1114
+ ): MultipartPayload {
1115
+ const filename = exactMultipartToken(file.filename, "upload filename");
1116
+ const mediaType = exactMultipartMediaType(file.mediaType);
1117
+ const safeFields = fields.map(({ name, value }) => ({
1118
+ name: exactMultipartToken(name, "multipart field name"),
1119
+ value: exactMultipartValue(value, `multipart field ${name}`),
1120
+ }));
1121
+ const fileBytes = Buffer.from(file.bytes);
1122
+ const collisionInputs = [
1123
+ fileBytes,
1124
+ ...safeFields.map((field) => Buffer.from(field.value, "utf8")),
1125
+ ];
1126
+ let boundary: string | null = null;
1127
+ for (let attempt = 0; attempt < 32; attempt += 1) {
1128
+ // Do not derive this header-visible value from private media bytes: even a
1129
+ // header-only log must not become a stable media fingerprint.
1130
+ const candidate = `wrench-${randomBytes(24).toString("hex")}`;
1131
+ const candidateBytes = Buffer.from(candidate, "ascii");
1132
+ if (!collisionInputs.some((input) => input.includes(candidateBytes))) {
1133
+ boundary = candidate;
1134
+ break;
1135
+ }
1136
+ }
1137
+ if (boundary === null) {
1138
+ throw new Error("official X could not construct an unambiguous multipart boundary");
1139
+ }
1140
+
1141
+ const chunks: Uint8Array[] = [];
1142
+ for (const field of safeFields) {
1143
+ chunks.push(Buffer.from(
1144
+ `--${boundary}\r\n`
1145
+ + `Content-Disposition: form-data; name="${field.name}"\r\n\r\n`
1146
+ + `${field.value}\r\n`,
1147
+ "utf8",
1148
+ ));
1149
+ }
1150
+ chunks.push(
1151
+ Buffer.from(
1152
+ `--${boundary}\r\n`
1153
+ + `Content-Disposition: form-data; name="media"; filename="${filename}"\r\n`
1154
+ + `Content-Type: ${mediaType}\r\n\r\n`,
1155
+ "utf8",
1156
+ ),
1157
+ fileBytes,
1158
+ Buffer.from(`\r\n--${boundary}--\r\n`, "utf8"),
1159
+ );
1160
+ return {
1161
+ body: Buffer.concat(chunks),
1162
+ contentType: `multipart/form-data; boundary=${boundary}`,
1163
+ };
1164
+ }
1165
+
1166
+ function multipartBearer(context: ProviderActionContext, contentType: string): Headers {
1167
+ const headers = bearer(context);
1168
+ headers.set("Content-Type", contentType);
1169
+ return headers;
1170
+ }
1171
+
1172
+ function mediaId(response: ProviderResponse, label: string): string {
1173
+ return exactXId(requiredString(dataRecord(response, label), "id", label), `official X ${label} id`);
1174
+ }
1175
+
1176
+ async function uploadOneShotImage(
1177
+ context: ProviderActionContext,
1178
+ prepared: PreparedProviderFile,
1179
+ category: string,
1180
+ ): Promise<UploadedMedia> {
1181
+ const { file } = prepared;
1182
+ const bytes = verifiedProviderRead(file, prepared.preflightIdentity, true).bytes;
1183
+ if (bytes === null) throw new Error("official X image could not be materialized safely");
1184
+ if (file.mediaType !== "image/jpeg" && file.mediaType !== "image/png") {
1185
+ throw new Error("official X one-shot upload supports only JPEG or PNG");
1186
+ }
1187
+ const uploadBytes = scrubXUploadImage(new Uint8Array(bytes), file.mediaType);
1188
+ const multipart = xMultipartPayload([
1189
+ { name: "media_category", value: category },
1190
+ { name: "media_type", value: file.mediaType },
1191
+ ], {
1192
+ bytes: uploadBytes,
1193
+ mediaType: file.mediaType,
1194
+ filename: genericUploadName(file.mediaType),
1195
+ });
1196
+ const response = await request(
1197
+ context,
1198
+ `${X_API_ORIGIN}/2/media/upload`,
1199
+ {
1200
+ method: "POST",
1201
+ headers: multipartBearer(context, multipart.contentType),
1202
+ body: new Uint8Array(multipart.body),
1203
+ },
1204
+ [200],
1205
+ );
1206
+ const id = mediaId(response, "media upload");
1207
+ await waitForMedia(context, id, response);
1208
+ return { id, category, mediaType: file.mediaType, bytes: file.bytes };
1209
+ }
1210
+
1211
+ type ProcessingState = {
1212
+ readonly state: "pending" | "in_progress" | "succeeded" | "failed" | null;
1213
+ readonly checkAfterSeconds: number;
1214
+ };
1215
+
1216
+ function processingState(response: ProviderResponse): ProcessingState {
1217
+ const data = dataRecord(response, "media processing");
1218
+ if (data.processing_info === undefined) return { state: null, checkAfterSeconds: 0 };
1219
+ if (!isRecord(data.processing_info)) {
1220
+ throw new Error("official X media processing response contained invalid processing_info");
1221
+ }
1222
+ const processing = data.processing_info;
1223
+ const stateValue = processing.state;
1224
+ if (stateValue !== "pending" && stateValue !== "in_progress" && stateValue !== "succeeded" && stateValue !== "failed") {
1225
+ throw new Error("official X media processing response contained an unsupported state");
1226
+ }
1227
+ const state = stateValue;
1228
+ const check = processing.check_after_secs;
1229
+ if (check !== undefined && (typeof check !== "number" || !Number.isFinite(check) || check < 0)) {
1230
+ throw new Error("official X media processing response contained an invalid check_after_secs");
1231
+ }
1232
+ const checkAfterSeconds = check === undefined ? 1 : check;
1233
+ return { state, checkAfterSeconds };
1234
+ }
1235
+
1236
+ async function waitForMedia(
1237
+ context: ProviderActionContext,
1238
+ id: string,
1239
+ initial: ProviderResponse,
1240
+ ): Promise<void> {
1241
+ let current = processingState(initial);
1242
+ if (current.state === null || current.state === "succeeded") return;
1243
+ if (current.state === "failed") throw new Error("official X media processing failed");
1244
+ const mediaDeadline = Date.now() + MAX_MEDIA_STATUS_WAIT_MS;
1245
+ for (let attempt = 0; attempt < MAX_MEDIA_STATUS_POLLS; attempt += 1) {
1246
+ const remaining = Math.min(mediaDeadline - Date.now(), context.http.remainingTimeMs());
1247
+ if (remaining < 1) break;
1248
+ const delay = current.checkAfterSeconds * 1_000;
1249
+ if (delay >= remaining) break;
1250
+ if (delay > 0) await new Promise<void>((resolve) => setTimeout(resolve, delay));
1251
+ const url = new URL(`${X_API_ORIGIN}/2/media/upload`);
1252
+ url.searchParams.set("command", "STATUS");
1253
+ url.searchParams.set("media_id", id);
1254
+ const response = await request(context, url, { method: "GET", headers: bearer(context) }, [200]);
1255
+ current = processingState(response);
1256
+ if (current.state === null || current.state === "succeeded") return;
1257
+ if (current.state === "failed") throw new Error("official X media processing failed");
1258
+ }
1259
+ throw new Error("official X media processing did not complete within the bounded polling window");
1260
+ }
1261
+
1262
+ async function uploadChunkedMedia(
1263
+ context: ProviderActionContext,
1264
+ prepared: PreparedProviderFile,
1265
+ category: string,
1266
+ ): Promise<UploadedMedia> {
1267
+ const { file } = prepared;
1268
+ if (file.mediaType === "image/gif") {
1269
+ const bytes = verifiedProviderRead(file, prepared.preflightIdentity, true).bytes;
1270
+ if (bytes === null) throw new Error("official X GIF could not be materialized safely");
1271
+ rejectGifProvenanceMarkers(bytes);
1272
+ }
1273
+ const opened = openProviderFile(file, prepared.preflightIdentity);
1274
+ let id: string;
1275
+ try {
1276
+ const initialize = await request(
1277
+ context,
1278
+ `${X_API_ORIGIN}/2/media/upload/initialize`,
1279
+ {
1280
+ method: "POST",
1281
+ headers: bearer(context, true),
1282
+ body: JSON.stringify({ media_type: file.mediaType, total_bytes: file.bytes, media_category: category }),
1283
+ },
1284
+ [200],
1285
+ );
1286
+ id = mediaId(initialize, "media initialization");
1287
+ const hash = createHash("sha256");
1288
+ let offset = 0;
1289
+ let segmentIndex = 0;
1290
+ while (offset < file.bytes) {
1291
+ const length = Math.min(MEDIA_CHUNK_BYTES, file.bytes - offset);
1292
+ const buffer = Buffer.allocUnsafe(length);
1293
+ let readOffset = 0;
1294
+ while (readOffset < length) {
1295
+ const count = readSync(opened.descriptor, buffer, readOffset, length - readOffset, offset + readOffset);
1296
+ if (count < 1) throw new Error("official X attachment ended during chunked upload");
1297
+ readOffset += count;
1298
+ }
1299
+ hash.update(buffer);
1300
+ const multipart = xMultipartPayload(
1301
+ [{ name: "segment_index", value: String(segmentIndex) }],
1302
+ {
1303
+ bytes: buffer,
1304
+ mediaType: file.mediaType,
1305
+ filename: genericUploadName(file.mediaType),
1306
+ },
1307
+ );
1308
+ await request(
1309
+ context,
1310
+ `${X_API_ORIGIN}/2/media/upload/${encodedSegment(id)}/append`,
1311
+ {
1312
+ method: "POST",
1313
+ headers: multipartBearer(context, multipart.contentType),
1314
+ body: new Uint8Array(multipart.body),
1315
+ },
1316
+ [200],
1317
+ );
1318
+ offset += length;
1319
+ segmentIndex += 1;
1320
+ }
1321
+ const after = fstatSync(opened.descriptor, { bigint: true });
1322
+ if (!sameProviderFile(opened.stats, after)) {
1323
+ throw new Error("official X attachment identity changed during chunked upload");
1324
+ }
1325
+ if (hash.digest("hex") !== file.sha256) {
1326
+ throw new Error("official X attachment no longer matches its confirmed digest during chunked upload");
1327
+ }
1328
+ } finally {
1329
+ closeProviderFile(opened.descriptor);
1330
+ }
1331
+
1332
+ // Plan assets are private and mode 0600, and both passes bind identity and
1333
+ // digest. A same-UID process that already holds the inode writable can still
1334
+ // alter bytes after the preflight; the upload pass detects that before
1335
+ // FINALIZE, although unfinalized chunks may already have reached X.
1336
+ const finalized = await request(
1337
+ context,
1338
+ `${X_API_ORIGIN}/2/media/upload/${encodedSegment(id)}/finalize`,
1339
+ { method: "POST", headers: bearer(context) },
1340
+ [200],
1341
+ );
1342
+ await waitForMedia(context, id, finalized);
1343
+ return { id, category, mediaType: file.mediaType, bytes: file.bytes };
1344
+ }
1345
+
1346
+ async function uploadMedia(
1347
+ context: ProviderActionContext,
1348
+ prepared: PreparedProviderFile,
1349
+ destination: "tweet" | "dm",
1350
+ ): Promise<UploadedMedia> {
1351
+ const { file } = prepared;
1352
+ validateMediaFile(file);
1353
+ const category = mediaCategory(file.mediaType, destination);
1354
+ return file.mediaType === "image/jpeg" || file.mediaType === "image/png"
1355
+ ? uploadOneShotImage(context, prepared, category)
1356
+ : uploadChunkedMedia(context, prepared, category);
1357
+ }
1358
+
1359
+ async function applyMediaAltText(
1360
+ context: ProviderActionContext,
1361
+ media: UploadedMedia,
1362
+ altText: string | undefined,
1363
+ ): Promise<UploadedMedia> {
1364
+ if (altText === undefined) return media;
1365
+ const response = await request(
1366
+ context,
1367
+ `${X_API_ORIGIN}/2/media/metadata`,
1368
+ {
1369
+ method: "POST",
1370
+ headers: bearer(context, true),
1371
+ body: JSON.stringify({ id: media.id, metadata: { alt_text: { text: altText } } }),
1372
+ },
1373
+ [200],
1374
+ );
1375
+ const result = dataRecord(response, "media metadata");
1376
+ const returnedId = exactXId(requiredString(result, "id", "media metadata"), "official X media metadata id");
1377
+ if (returnedId !== media.id) throw new Error("official X media metadata response returned an unexpected media ID");
1378
+ const associatedMetadata = record(result.associated_metadata, "media metadata associated_metadata");
1379
+ const returnedAltText = requiredString(
1380
+ record(associatedMetadata.alt_text, "media metadata alt_text"),
1381
+ "text",
1382
+ "media metadata alt_text",
1383
+ );
1384
+ if (returnedAltText !== altText) {
1385
+ throw new Error("official X media metadata response returned unexpected alternative text");
1386
+ }
1387
+ return { ...media, altText };
1388
+ }
1389
+
1390
+ function alignedAltTexts(
1391
+ context: ProviderActionContext,
1392
+ inputName: string,
1393
+ mediaCount: number,
1394
+ ): readonly (string | undefined)[] {
1395
+ const values = optionalInputStrings(context, inputName);
1396
+ if (values === undefined) return Array.from({ length: mediaCount }, () => undefined);
1397
+ if (values.length !== mediaCount) throw new Error(`input.${inputName} must align one-to-one with the reviewed media`);
1398
+ return values;
1399
+ }
1400
+
1401
+ function validateImageAltTexts(
1402
+ files: readonly ProviderFile[],
1403
+ altTexts: readonly (string | undefined)[],
1404
+ inputName: string,
1405
+ ): void {
1406
+ for (const [index, altText] of altTexts.entries()) {
1407
+ if (altText === undefined) continue;
1408
+ const mediaType = files[index]?.mediaType;
1409
+ if (mediaType !== "image/jpeg" && mediaType !== "image/png") {
1410
+ throw new Error(`input.${inputName}[${index}] can describe only a JPEG or PNG attachment`);
1411
+ }
1412
+ }
1413
+ }
1414
+
1415
+ function createdPostId(response: ProviderResponse): string {
1416
+ return exactXId(requiredString(dataRecord(response, "post creation"), "id", "post creation"), "official X created post id");
1417
+ }
1418
+
1419
+ async function executePostsPublish(context: ProviderActionContext): Promise<void> {
1420
+ const body = optionalInputString(context, "body");
1421
+ const files = await context.resolveFiles("media");
1422
+ validatePostMedia(files);
1423
+ const altTexts = alignedAltTexts(context, "media_alt_texts", files.length);
1424
+ validateImageAltTexts(files, altTexts, "media_alt_texts");
1425
+ const pollOptions = context.input.poll_options;
1426
+ const pollDuration = optionalInputNumber(context, "poll_duration_minutes");
1427
+ if ((Array.isArray(pollOptions)) !== (pollDuration !== undefined)) {
1428
+ throw new Error("input.poll_options and input.poll_duration_minutes must be supplied together");
1429
+ }
1430
+ if (Array.isArray(pollOptions) && files.length > 0) throw new Error("input.media and poll inputs are mutually exclusive");
1431
+ if (body === undefined && files.length === 0 && !Array.isArray(pollOptions)) {
1432
+ throw new Error("official X posts require text, reviewed media, or a poll");
1433
+ }
1434
+ const replySettings = optionalInputString(context, "reply_settings");
1435
+ if (replySettings !== undefined && !["everyone", "mentionedUsers", "following", "subscribers", "verified"].includes(replySettings)) {
1436
+ throw new Error("input.reply_settings is not supported by the bundled X contract");
1437
+ }
1438
+ const communityIdValue = optionalInputString(context, "community_id");
1439
+ const communityId = communityIdValue === undefined ? undefined : exactXId(communityIdValue, "input.community_id");
1440
+ const madeWithAi = optionalInputBoolean(context, "made_with_ai");
1441
+ if (madeWithAi === true && files.length === 0) {
1442
+ throw new Error("input.made_with_ai is true but the post has no reviewed media");
1443
+ }
1444
+ const preparedFiles = preflightProviderFiles(files);
1445
+ if (files.length > 0) context.addRequiredScopes(["media.write"]);
1446
+ await authenticatedWriteUserId(context);
1447
+ await context.dispatch(async () => {
1448
+ const uploaded: UploadedMedia[] = [];
1449
+ for (const [index, prepared] of preparedFiles.entries()) {
1450
+ const media = await uploadMedia(context, prepared, "tweet");
1451
+ uploaded.push(await applyMediaAltText(context, media, altTexts[index]));
1452
+ context.setOutput({ provider: "x", operation: "posts.publish", published: false, uploadedMedia: uploaded });
1453
+ }
1454
+ const payload: JsonRecord = {};
1455
+ if (body !== undefined) payload.text = body;
1456
+ if (uploaded.length > 0) payload.media = { media_ids: uploaded.map((media) => media.id) };
1457
+ if (Array.isArray(pollOptions) && pollDuration !== undefined) {
1458
+ payload.poll = { options: pollOptions, duration_minutes: pollDuration };
1459
+ }
1460
+ if (replySettings !== undefined && replySettings !== "everyone") payload.reply_settings = replySettings;
1461
+ if (communityId !== undefined) payload.community_id = communityId;
1462
+ if (officialMadeWithAiLabel(madeWithAi) === true) {
1463
+ payload.made_with_ai = true;
1464
+ }
1465
+ const response = await request(
1466
+ context,
1467
+ `${X_API_ORIGIN}/2/tweets`,
1468
+ { method: "POST", headers: bearer(context, true), body: JSON.stringify(payload) },
1469
+ [201],
1470
+ );
1471
+ const id = createdPostId(response);
1472
+ const url = postUrl(id);
1473
+ const post = dataRecord(response, "post creation");
1474
+ if (officialMadeWithAiLabel(madeWithAi) !== true) {
1475
+ rejectXTweetMadeWithAiLabel(post, { id, url });
1476
+ }
1477
+ context.setOutput({
1478
+ provider: "x",
1479
+ operation: "posts.publish",
1480
+ published: true,
1481
+ post,
1482
+ uploadedMedia: uploaded,
1483
+ url,
1484
+ });
1485
+ context.setFinalUrl(url);
1486
+ });
1487
+ }
1488
+
1489
+ async function executeReplyCreate(context: ProviderActionContext): Promise<void> {
1490
+ if (context.input.recipient_opted_in !== true || context.input.author_invited_reply !== true) {
1491
+ throw new Error("official X automated replies require recipient opt-in and an author-invitation acknowledgement");
1492
+ }
1493
+ const body = optionalInputString(context, "body");
1494
+ const targetPostId = inputXId(context, "target_post_id");
1495
+ const files = await context.resolveFiles("media");
1496
+ validatePostMedia(files);
1497
+ const altTexts = alignedAltTexts(context, "media_alt_texts", files.length);
1498
+ validateImageAltTexts(files, altTexts, "media_alt_texts");
1499
+ if (body === undefined && files.length === 0) {
1500
+ throw new Error("official X replies require text or reviewed media");
1501
+ }
1502
+ const preparedFiles = preflightProviderFiles(files);
1503
+ if (files.length > 0) context.addRequiredScopes(["media.write"]);
1504
+ await authenticatedWriteUserId(context);
1505
+ await context.dispatch(async () => {
1506
+ const uploaded: UploadedMedia[] = [];
1507
+ for (const [index, prepared] of preparedFiles.entries()) {
1508
+ const media = await uploadMedia(context, prepared, "tweet");
1509
+ uploaded.push(await applyMediaAltText(context, media, altTexts[index]));
1510
+ context.setOutput({ provider: "x", operation: "replies.create", published: false, uploadedMedia: uploaded });
1511
+ }
1512
+ const payload: JsonRecord = { reply: { in_reply_to_tweet_id: targetPostId } };
1513
+ if (body !== undefined) payload.text = body;
1514
+ if (uploaded.length > 0) payload.media = { media_ids: uploaded.map((media) => media.id) };
1515
+ const response = await request(
1516
+ context,
1517
+ `${X_API_ORIGIN}/2/tweets`,
1518
+ { method: "POST", headers: bearer(context, true), body: JSON.stringify(payload) },
1519
+ [201],
1520
+ );
1521
+ const id = createdPostId(response);
1522
+ const url = postUrl(id);
1523
+ context.setOutput({
1524
+ provider: "x",
1525
+ operation: "replies.create",
1526
+ published: true,
1527
+ post: dataRecord(response, "post creation"),
1528
+ uploadedMedia: uploaded,
1529
+ url,
1530
+ });
1531
+ context.setFinalUrl(url);
1532
+ });
1533
+ }
1534
+
1535
+ async function executeThreadPublish(context: ProviderActionContext): Promise<void> {
1536
+ const items = inputStrings(context, "items");
1537
+ const files = await context.resolveFiles("media");
1538
+ const mediaIndices = files.length === 0 && context.input.media_item_indices === undefined
1539
+ ? []
1540
+ : inputNumbers(context, "media_item_indices");
1541
+ if (files.length !== mediaIndices.length) {
1542
+ throw new Error("input.media_item_indices must align one-to-one with the reviewed thread media");
1543
+ }
1544
+ const altTexts = alignedAltTexts(context, "media_alt_texts", files.length);
1545
+ validateImageAltTexts(files, altTexts, "media_alt_texts");
1546
+ const groupedFiles = new Map<number, ProviderFile[]>();
1547
+ for (const [index, file] of files.entries()) {
1548
+ const itemIndex = mediaIndices[index];
1549
+ if (itemIndex === undefined || itemIndex < 1 || itemIndex > items.length) {
1550
+ throw new Error(`input.media_item_indices[${index}] must name an existing one-based thread item`);
1551
+ }
1552
+ const group = groupedFiles.get(itemIndex) ?? [];
1553
+ group.push(file);
1554
+ groupedFiles.set(itemIndex, group);
1555
+ }
1556
+ for (const group of groupedFiles.values()) validatePostMedia(group);
1557
+ const grouped = new Map<number, Array<{ readonly prepared: PreparedProviderFile; readonly altText: string | undefined }>>();
1558
+ const preparedFiles = preflightProviderFiles(files);
1559
+ for (const [index, prepared] of preparedFiles.entries()) {
1560
+ const itemIndex = mediaIndices[index];
1561
+ if (itemIndex === undefined) throw new Error("reviewed thread media lost its validated item binding");
1562
+ const group = grouped.get(itemIndex) ?? [];
1563
+ group.push({ prepared, altText: altTexts[index] });
1564
+ grouped.set(itemIndex, group);
1565
+ }
1566
+ if (files.length > 0) context.addRequiredScopes(["media.write"]);
1567
+ await authenticatedWriteUserId(context);
1568
+ const committed: Array<{
1569
+ readonly index: number;
1570
+ readonly id: string;
1571
+ readonly url: string;
1572
+ readonly media?: readonly UploadedMedia[];
1573
+ }> = [];
1574
+ context.setOutput({ provider: "x", operation: "threads.publish", complete: false, committed });
1575
+ let previousId: string | null = null;
1576
+ for (const [index, text] of items.entries()) {
1577
+ await context.dispatch(async () => {
1578
+ const payload: JsonRecord = { text };
1579
+ if (previousId !== null) payload.reply = { in_reply_to_tweet_id: previousId };
1580
+ const uploaded: UploadedMedia[] = [];
1581
+ for (const value of grouped.get(index + 1) ?? []) {
1582
+ const media = await uploadMedia(context, value.prepared, "tweet");
1583
+ uploaded.push(await applyMediaAltText(context, media, value.altText));
1584
+ context.setOutput({
1585
+ provider: "x",
1586
+ operation: "threads.publish",
1587
+ complete: false,
1588
+ committed: [...committed],
1589
+ pending: { index: index + 1, uploadedMedia: [...uploaded] },
1590
+ });
1591
+ }
1592
+ if (uploaded.length > 0) payload.media = { media_ids: uploaded.map((media) => media.id) };
1593
+ const response = await request(
1594
+ context,
1595
+ `${X_API_ORIGIN}/2/tweets`,
1596
+ { method: "POST", headers: bearer(context, true), body: JSON.stringify(payload) },
1597
+ [201],
1598
+ );
1599
+ const id = createdPostId(response);
1600
+ previousId = id;
1601
+ committed.push({
1602
+ index: index + 1,
1603
+ id,
1604
+ url: postUrl(id),
1605
+ ...(uploaded.length === 0 ? {} : { media: [...uploaded] }),
1606
+ });
1607
+ context.setOutput({
1608
+ provider: "x",
1609
+ operation: "threads.publish",
1610
+ complete: committed.length === items.length,
1611
+ committed: [...committed],
1612
+ });
1613
+ });
1614
+ }
1615
+ const first = committed[0];
1616
+ if (first !== undefined) context.setFinalUrl(first.url);
1617
+ }
1618
+
1619
+ async function executeMessagingSend(context: ProviderActionContext): Promise<void> {
1620
+ if (context.input.recipient_opted_in !== true) {
1621
+ throw new Error("official X automated Direct Messages require explicit recipient opt-in acknowledgement");
1622
+ }
1623
+ const body = optionalInputString(context, "body");
1624
+ const targetKind = inputString(context, "target_kind");
1625
+ const targetId = targetKind === "participant"
1626
+ ? inputXId(context, "target_id")
1627
+ : targetKind === "conversation"
1628
+ ? exactDmConversationId(inputString(context, "target_id"), "input.target_id")
1629
+ : null;
1630
+ if (targetId === null) throw new Error("input.target_kind names an unsupported official X DM target");
1631
+ if (targetKind === "participant" && targetId === context.auth.subject) {
1632
+ throw new Error("input.target_id must identify another X DM participant");
1633
+ }
1634
+ const path = targetKind === "participant"
1635
+ ? `/2/dm_conversations/with/${encodedSegment(targetId)}/messages`
1636
+ : `/2/dm_conversations/${encodedSegment(targetId)}/messages`;
1637
+ const files = await context.resolveFiles("media");
1638
+ if (files.length > 1) throw new Error("official X Direct Messages support at most one attachment");
1639
+ const file = files[0];
1640
+ const mediaAltText = optionalInputString(context, "media_alt_text");
1641
+ if (mediaAltText !== undefined && file === undefined) throw new Error("input.media_alt_text requires input.media");
1642
+ if (mediaAltText !== undefined && file !== undefined
1643
+ && file.mediaType !== "image/jpeg" && file.mediaType !== "image/png") {
1644
+ throw new Error("input.media_alt_text can describe only a JPEG or PNG attachment");
1645
+ }
1646
+ if (body === undefined && file === undefined) {
1647
+ throw new Error("official X Direct Messages require text or one reviewed attachment");
1648
+ }
1649
+ let preparedFile: PreparedProviderFile | undefined;
1650
+ if (file !== undefined) {
1651
+ validateMediaFile(file);
1652
+ preparedFile = preflightProviderFile(file);
1653
+ context.addRequiredScopes(["media.write"]);
1654
+ }
1655
+ await authenticatedWriteUserId(context);
1656
+ await context.dispatch(async () => {
1657
+ const uploadedRaw = preparedFile === undefined ? null : await uploadMedia(context, preparedFile, "dm");
1658
+ const uploaded = uploadedRaw === null ? null : await applyMediaAltText(context, uploadedRaw, mediaAltText);
1659
+ if (uploaded !== null) {
1660
+ context.setOutput({ provider: "x", operation: "messaging.send", sent: false, uploadedMedia: [uploaded] });
1661
+ }
1662
+ const payload: JsonRecord = {};
1663
+ if (body !== undefined) payload.text = body;
1664
+ if (uploaded !== null) payload.attachments = [{ media_id: uploaded.id }];
1665
+ const response = await request(
1666
+ context,
1667
+ `${X_API_ORIGIN}${path}`,
1668
+ { method: "POST", headers: bearer(context, true), body: JSON.stringify(payload) },
1669
+ [201],
1670
+ );
1671
+ const result = dataRecord(response, "DM send");
1672
+ const conversationId = exactDmConversationId(
1673
+ requiredString(result, "dm_conversation_id", "DM send"),
1674
+ "official X sent DM conversation id",
1675
+ );
1676
+ if (targetKind === "conversation" && conversationId !== targetId) {
1677
+ throw new Error("official X DM send response returned an unexpected conversation ID");
1678
+ }
1679
+ if (targetKind === "participant") {
1680
+ const expected = new Set([exactXId(context.auth.subject ?? "", "OAuth locator subject"), targetId]);
1681
+ const returned = exactDirectDmConversationSet(conversationId, "official X sent DM conversation id");
1682
+ if (!sameIdSet(expected, returned)) {
1683
+ throw new Error("official X DM send response returned an unexpected participant conversation");
1684
+ }
1685
+ }
1686
+ context.setOutput({
1687
+ provider: "x",
1688
+ operation: "messaging.send",
1689
+ sent: true,
1690
+ eventId: exactXId(requiredString(result, "dm_event_id", "DM send"), "official X sent DM event id"),
1691
+ conversationId,
1692
+ uploadedMedia: uploaded === null ? [] : [uploaded],
1693
+ coverage: {
1694
+ encryptedChat: false,
1695
+ limitation: "This sends through the official DM-event API, not encrypted Chat.",
1696
+ },
1697
+ });
1698
+ });
1699
+ }
1700
+
1701
+ async function executeRepost(context: ProviderActionContext): Promise<void> {
1702
+ const postId = inputXId(context, "post_id");
1703
+ const me = await authenticatedWriteUserId(context);
1704
+ const enabled = context.input.enabled === true;
1705
+ await context.dispatch(async () => {
1706
+ const response = enabled
1707
+ ? await request(
1708
+ context,
1709
+ `${X_API_ORIGIN}/2/users/${encodedSegment(me)}/retweets`,
1710
+ { method: "POST", headers: bearer(context, true), body: JSON.stringify({ tweet_id: postId }) },
1711
+ [200],
1712
+ )
1713
+ : await request(
1714
+ context,
1715
+ `${X_API_ORIGIN}/2/users/${encodedSegment(me)}/retweets/${encodedSegment(postId)}`,
1716
+ { method: "DELETE", headers: bearer(context) },
1717
+ [200],
1718
+ );
1719
+ const result = dataRecord(response, "repost desired state");
1720
+ if (result.retweeted !== enabled) {
1721
+ throw new Error("official X repost response did not confirm the requested desired state");
1722
+ }
1723
+ context.setOutput({
1724
+ provider: "x",
1725
+ operation: "posts.repost",
1726
+ postId,
1727
+ enabled,
1728
+ result,
1729
+ });
1730
+ context.setFinalUrl(postUrl(postId));
1731
+ });
1732
+ }
1733
+
1734
+ async function executeBookmark(context: ProviderActionContext): Promise<void> {
1735
+ const postId = inputXId(context, "post_id");
1736
+ const me = await authenticatedWriteUserId(context);
1737
+ const enabled = context.input.enabled === true;
1738
+ await context.dispatch(async () => {
1739
+ const response = enabled
1740
+ ? await request(
1741
+ context,
1742
+ `${X_API_ORIGIN}/2/users/${encodedSegment(me)}/bookmarks`,
1743
+ { method: "POST", headers: bearer(context, true), body: JSON.stringify({ tweet_id: postId }) },
1744
+ [200],
1745
+ )
1746
+ : await request(
1747
+ context,
1748
+ `${X_API_ORIGIN}/2/users/${encodedSegment(me)}/bookmarks/${encodedSegment(postId)}`,
1749
+ { method: "DELETE", headers: bearer(context) },
1750
+ [200],
1751
+ );
1752
+ const result = dataRecord(response, "bookmark desired state");
1753
+ if (result.bookmarked !== enabled) {
1754
+ throw new Error("official X bookmark response did not confirm the requested desired state");
1755
+ }
1756
+ context.setOutput({
1757
+ provider: "x",
1758
+ operation: "content.save",
1759
+ postId,
1760
+ enabled,
1761
+ result,
1762
+ });
1763
+ context.setFinalUrl(postUrl(postId));
1764
+ });
1765
+ }
1766
+
1767
+ function draftBlocks(body: string): readonly JsonRecord[] {
1768
+ return body.split("\n").map((text) => ({
1769
+ type: "unstyled",
1770
+ text,
1771
+ data: {},
1772
+ entity_ranges: [],
1773
+ inline_style_ranges: [],
1774
+ }));
1775
+ }
1776
+
1777
+ async function executeArticle(
1778
+ context: ProviderActionContext,
1779
+ operation: "articles.draft.save" | "articles.publish",
1780
+ ): Promise<void> {
1781
+ const retainedV2DraftOnly = operation === "articles.publish"
1782
+ && context.recipe.contractVersion === 2
1783
+ && context.input.draft_only === true;
1784
+ if (context.input.draft_only !== undefined && !retainedV2DraftOnly) {
1785
+ throw new Error("input.draft_only is unsupported; use articles.draft.save to save a private X Article draft");
1786
+ }
1787
+ const title = inputString(context, "title");
1788
+ const body = inputString(context, "body");
1789
+ const covers = await context.resolveFiles("cover");
1790
+ if (covers.length > 1) throw new Error("official X Articles support one cover image");
1791
+ const cover = covers[0];
1792
+ const coverAltText = optionalInputString(context, "cover_alt_text");
1793
+ if (coverAltText !== undefined && cover === undefined) throw new Error("input.cover_alt_text requires input.cover");
1794
+ let preparedCover: PreparedProviderFile | undefined;
1795
+ if (cover !== undefined) {
1796
+ validateMediaFile(cover);
1797
+ if (cover.mediaType !== "image/jpeg" && cover.mediaType !== "image/png") {
1798
+ throw new Error("official X Article covers must be JPEG or PNG images");
1799
+ }
1800
+ preparedCover = preflightProviderFile(cover);
1801
+ context.addRequiredScopes(["media.write"]);
1802
+ }
1803
+ await authenticatedWriteUserId(context);
1804
+ await context.dispatch(async () => {
1805
+ const uploadedRaw = preparedCover === undefined ? null : await uploadMedia(context, preparedCover, "tweet");
1806
+ const uploaded = uploadedRaw === null ? null : await applyMediaAltText(context, uploadedRaw, coverAltText);
1807
+ if (uploaded !== null) {
1808
+ context.setOutput({ provider: "x", operation, published: false, uploadedCover: uploaded });
1809
+ }
1810
+ const draftPayload: JsonRecord = {
1811
+ title,
1812
+ content_state: {
1813
+ blocks: draftBlocks(body),
1814
+ entities: [],
1815
+ },
1816
+ };
1817
+ if (uploaded !== null) {
1818
+ draftPayload.cover_media = { media_category: uploaded.category, media_id: uploaded.id };
1819
+ }
1820
+ const draftResponse = await request(
1821
+ context,
1822
+ `${X_API_ORIGIN}/2/articles/draft`,
1823
+ { method: "POST", headers: bearer(context, true), body: JSON.stringify(draftPayload) },
1824
+ [201],
1825
+ );
1826
+ const draft = dataRecord(draftResponse, "Article draft creation");
1827
+ const articleId = exactXId(requiredString(draft, "id", "Article draft creation"), "official X Article id");
1828
+ const returnedTitle = requiredString(draft, "title", "Article draft creation");
1829
+ if (returnedTitle !== title) {
1830
+ throw new Error("official X Article draft response returned an unexpected title");
1831
+ }
1832
+ context.setOutput({
1833
+ provider: "x",
1834
+ operation,
1835
+ published: false,
1836
+ mode: operation === "articles.draft.save" || retainedV2DraftOnly
1837
+ ? "draft"
1838
+ : "publish",
1839
+ draftId: articleId,
1840
+ draft,
1841
+ uploadedCover: uploaded,
1842
+ });
1843
+ if (operation === "articles.draft.save" || retainedV2DraftOnly) return;
1844
+ const publishResponse = await request(
1845
+ context,
1846
+ `${X_API_ORIGIN}/2/articles/${encodedSegment(articleId)}/publish`,
1847
+ { method: "POST", headers: bearer(context) },
1848
+ [200],
1849
+ );
1850
+ const published = dataRecord(publishResponse, "Article publish");
1851
+ const postId = exactXId(requiredString(published, "post_id", "Article publish"), "official X Article post id");
1852
+ const url = postUrl(postId);
1853
+ context.setOutput({
1854
+ provider: "x",
1855
+ operation: "articles.publish",
1856
+ published: true,
1857
+ draftId: articleId,
1858
+ postId,
1859
+ draft,
1860
+ result: published,
1861
+ uploadedCover: uploaded,
1862
+ url,
1863
+ });
1864
+ context.setFinalUrl(url);
1865
+ });
1866
+ }
1867
+
1868
+ /** Execute one reviewed, code-owned official X API action. */
1869
+ export async function executeXProvider(context: ProviderActionContext): Promise<void> {
1870
+ // All numeric fields in the bundled X contracts are API integers. Validate
1871
+ // them before any action-specific identity lookup or remote request.
1872
+ optionalInputNumber(context, "limit");
1873
+ optionalInputNumber(context, "poll_duration_minutes");
1874
+ const action = context.recipe.action;
1875
+ if (action === "feeds.read") await executeFeedsRead(context);
1876
+ else if (action === "posts.read") await executePostsRead(context);
1877
+ else if (action === "comments.read") await executeCommentsRead(context);
1878
+ else if (action === "messaging.list") await executeMessagingList(context);
1879
+ else if (action === "messaging.read") await executeMessagingRead(context);
1880
+ else if (action === "messaging.send") await executeMessagingSend(context);
1881
+ else if (action === "posts.publish") await executePostsPublish(context);
1882
+ else if (action === "replies.create") await executeReplyCreate(context);
1883
+ else if (action === "threads.publish") await executeThreadPublish(context);
1884
+ else if (action === "posts.repost") await executeRepost(context);
1885
+ else if (action === "content.save") await executeBookmark(context);
1886
+ else if (action === "articles.draft.save") await executeArticle(context, action);
1887
+ else if (action === "articles.publish") await executeArticle(context, action);
1888
+ else throw new Error(`official X provider action ${action} is not implemented`);
1889
+ }