@crievo/omni 0.0.0-stage → 0.1.1

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 (708) hide show
  1. package/.env.example +41 -0
  2. package/README.md +2 -2
  3. package/bin/omni.mjs +18 -0
  4. package/docs/onboarding/clone-voice.png +0 -0
  5. package/docs/onboarding/getting-started.png +0 -0
  6. package/docs/onboarding/new-video.png +0 -0
  7. package/docs/onboarding/providers.png +0 -0
  8. package/docs/onboarding/publish.png +0 -0
  9. package/docs/onboarding/references.png +0 -0
  10. package/docs/onboarding.md +212 -0
  11. package/omnivideo.config.example.json +437 -0
  12. package/package.json +40 -4
  13. package/reference/captions.md +156 -0
  14. package/reference/carousel-contract.md +91 -0
  15. package/reference/cloud.md +96 -0
  16. package/reference/learnings.md +792 -0
  17. package/reference/omnichat.md +305 -0
  18. package/reference/premiere-mcp.md +91 -0
  19. package/reference/publish.md +305 -0
  20. package/reference/scenes-contract.md +109 -0
  21. package/reference/studio.md +666 -0
  22. package/reference/voicestudio.md +81 -0
  23. package/reference/writing-for-voice.md +67 -0
  24. package/requirements.txt +3 -0
  25. package/studio/api/app.ts +87 -0
  26. package/studio/api/lib/errors.ts +33 -0
  27. package/studio/api/lib/files.ts +41 -0
  28. package/studio/api/lib/hono.ts +9 -0
  29. package/studio/api/lib/result.ts +14 -0
  30. package/studio/api/lib/validate.ts +31 -0
  31. package/studio/api/lib/video.ts +10 -0
  32. package/studio/api/middleware/json-charset.ts +11 -0
  33. package/studio/api/middleware/local-only.ts +32 -0
  34. package/studio/api/middleware/no-timeout.ts +10 -0
  35. package/studio/api/routes/admin.ts +18 -0
  36. package/studio/api/routes/agent-cascade.ts +100 -0
  37. package/studio/api/routes/agent-history.ts +23 -0
  38. package/studio/api/routes/agent.ts +162 -0
  39. package/studio/api/routes/analytics.ts +31 -0
  40. package/studio/api/routes/approval.ts +60 -0
  41. package/studio/api/routes/archive.ts +12 -0
  42. package/studio/api/routes/captions.ts +28 -0
  43. package/studio/api/routes/carousel.ts +38 -0
  44. package/studio/api/routes/clone.ts +27 -0
  45. package/studio/api/routes/create.ts +19 -0
  46. package/studio/api/routes/events.ts +11 -0
  47. package/studio/api/routes/export.ts +14 -0
  48. package/studio/api/routes/media.ts +30 -0
  49. package/studio/api/routes/omnichat.ts +113 -0
  50. package/studio/api/routes/onboarding.ts +14 -0
  51. package/studio/api/routes/publish.ts +40 -0
  52. package/studio/api/routes/queue.ts +14 -0
  53. package/studio/api/routes/references.ts +40 -0
  54. package/studio/api/routes/runner.ts +17 -0
  55. package/studio/api/routes/schedule.ts +47 -0
  56. package/studio/api/routes/services.ts +11 -0
  57. package/studio/api/routes/sessions.ts +27 -0
  58. package/studio/api/routes/settings.ts +102 -0
  59. package/studio/api/routes/sound.ts +45 -0
  60. package/studio/api/routes/thumbnail.ts +18 -0
  61. package/studio/api/routes/usage.ts +13 -0
  62. package/studio/api/routes/videos.ts +41 -0
  63. package/studio/api/routes/voices.ts +71 -0
  64. package/studio/api/services/admin.ts +113 -0
  65. package/studio/api/services/agent-cascade.ts +975 -0
  66. package/studio/api/services/agent-claude.ts +47 -0
  67. package/studio/api/services/agent-history.ts +103 -0
  68. package/studio/api/services/agent-persona.ts +77 -0
  69. package/studio/api/services/agent-tools.ts +2237 -0
  70. package/studio/api/services/agent-ui-tools.ts +259 -0
  71. package/studio/api/services/agent-wait.ts +32 -0
  72. package/studio/api/services/agent.ts +553 -0
  73. package/studio/api/services/analytics.ts +92 -0
  74. package/studio/api/services/approval.ts +134 -0
  75. package/studio/api/services/archive.ts +38 -0
  76. package/studio/api/services/captions.ts +268 -0
  77. package/studio/api/services/carousel.ts +206 -0
  78. package/studio/api/services/classify.ts +196 -0
  79. package/studio/api/services/clone-phrases.ts +75 -0
  80. package/studio/api/services/clone.ts +722 -0
  81. package/studio/api/services/create.ts +421 -0
  82. package/studio/api/services/events.ts +215 -0
  83. package/studio/api/services/media.ts +32 -0
  84. package/studio/api/services/omnichat.ts +624 -0
  85. package/studio/api/services/onboarding.ts +77 -0
  86. package/studio/api/services/publish.ts +287 -0
  87. package/studio/api/services/queue.ts +150 -0
  88. package/studio/api/services/references.ts +269 -0
  89. package/studio/api/services/root.ts +60 -0
  90. package/studio/api/services/services.ts +114 -0
  91. package/studio/api/services/sessions.ts +1007 -0
  92. package/studio/api/services/settings.ts +983 -0
  93. package/studio/api/services/sound.ts +311 -0
  94. package/studio/api/services/thumbnail.ts +133 -0
  95. package/studio/api/services/usage.ts +27 -0
  96. package/studio/api/services/videos.ts +418 -0
  97. package/studio/api/services/voice-pause.ts +98 -0
  98. package/studio/api/services/voices.ts +324 -0
  99. package/studio/runner/runner.ts +244 -0
  100. package/studio/shared/omni-actions.ts +68 -0
  101. package/studio/shared/omni-admin.ts +52 -0
  102. package/studio/shared/omni-farewell.ts +48 -0
  103. package/studio/shared/omni-hotword.ts +20 -0
  104. package/studio/shared/omni-screens.ts +228 -0
  105. package/studio/shared/schemas/admin.ts +15 -0
  106. package/studio/shared/schemas/agent-cascade.ts +58 -0
  107. package/studio/shared/schemas/agent-history.ts +73 -0
  108. package/studio/shared/schemas/agent.ts +50 -0
  109. package/studio/shared/schemas/analytics.ts +6 -0
  110. package/studio/shared/schemas/approval.ts +22 -0
  111. package/studio/shared/schemas/captions.ts +19 -0
  112. package/studio/shared/schemas/carousel.ts +26 -0
  113. package/studio/shared/schemas/clone.ts +40 -0
  114. package/studio/shared/schemas/common.ts +16 -0
  115. package/studio/shared/schemas/omnichat.ts +54 -0
  116. package/studio/shared/schemas/onboarding.ts +5 -0
  117. package/studio/shared/schemas/publish.ts +45 -0
  118. package/studio/shared/schemas/references.ts +20 -0
  119. package/studio/shared/schemas/settings.ts +82 -0
  120. package/studio/shared/schemas/sound.ts +22 -0
  121. package/studio/shared/schemas/videos.ts +38 -0
  122. package/studio/shared/schemas/voices.ts +23 -0
  123. package/studio/shared/types/api.ts +1290 -0
  124. package/studio/studio.ts +148 -0
  125. package/studio/tsconfig.json +15 -0
  126. package/studio/web/assets/logos/cartesia.svg +4 -0
  127. package/studio/web/assets/logos/elevenlabs.svg +12 -0
  128. package/studio/web/assets/logos/fish.svg +24 -0
  129. package/studio/web/assets/logos/omni-black.png +0 -0
  130. package/studio/web/assets/logos/omni-white.png +0 -0
  131. package/studio/web/assets/logos/voicestudio.svg +21 -0
  132. package/studio/web/components/AgentMark.tsx +7 -0
  133. package/studio/web/components/AgentWalking.tsx +11 -0
  134. package/studio/web/components/ApiKeyField.tsx +104 -0
  135. package/studio/web/components/AppShell.tsx +64 -0
  136. package/studio/web/components/ApprovalNotice.tsx +65 -0
  137. package/studio/web/components/AvatarGroupIcon.tsx +22 -0
  138. package/studio/web/components/AvatarNameField.tsx +86 -0
  139. package/studio/web/components/AvatarPicker.tsx +84 -0
  140. package/studio/web/components/BrandLogo.tsx +16 -0
  141. package/studio/web/components/CartesiaPlanSetting.tsx +90 -0
  142. package/studio/web/components/ClaudeWalking.tsx +32 -0
  143. package/studio/web/components/CloneVoiceDialog/GuidedScript.tsx +39 -0
  144. package/studio/web/components/CloneVoiceDialog/LanguageBar.tsx +37 -0
  145. package/studio/web/components/CloneVoiceDialog/LevelMeter.tsx +31 -0
  146. package/studio/web/components/CloneVoiceDialog/ProviderChoice.tsx +81 -0
  147. package/studio/web/components/CloneVoiceDialog/QualityPanel.tsx +62 -0
  148. package/studio/web/components/CloneVoiceDialog/RecordStep.tsx +50 -0
  149. package/studio/web/components/CloneVoiceDialog/RecorderBar.tsx +79 -0
  150. package/studio/web/components/CloneVoiceDialog/RedoSummary.tsx +54 -0
  151. package/studio/web/components/CloneVoiceDialog/SaveStep.tsx +91 -0
  152. package/studio/web/components/CloneVoiceDialog/SourceBar.tsx +30 -0
  153. package/studio/web/components/CloneVoiceDialog/index.tsx +141 -0
  154. package/studio/web/components/CloneVoiceDialog/types.ts +28 -0
  155. package/studio/web/components/CloneVoiceDialog/useCloneVoice.ts +528 -0
  156. package/studio/web/components/ConnectionSignals.tsx +51 -0
  157. package/studio/web/components/EditVoiceDialog/EditVoiceFooter.tsx +72 -0
  158. package/studio/web/components/EditVoiceDialog/ProviderCards.tsx +81 -0
  159. package/studio/web/components/EditVoiceDialog/index.tsx +256 -0
  160. package/studio/web/components/ErrorBanner.tsx +115 -0
  161. package/studio/web/components/FinishedTask.tsx +41 -0
  162. package/studio/web/components/FishModelSetting.tsx +69 -0
  163. package/studio/web/components/FishPriceSetting.tsx +44 -0
  164. package/studio/web/components/HumanBanner.tsx +64 -0
  165. package/studio/web/components/Lightbox.tsx +41 -0
  166. package/studio/web/components/LogStream.tsx +42 -0
  167. package/studio/web/components/Markdown.tsx +21 -0
  168. package/studio/web/components/MissingDmsPanel/BatchSend.tsx +137 -0
  169. package/studio/web/components/MissingDmsPanel/CommentCard.tsx +168 -0
  170. package/studio/web/components/MissingDmsPanel/CommentFeed.tsx +45 -0
  171. package/studio/web/components/MissingDmsPanel/DmBubble.tsx +38 -0
  172. package/studio/web/components/MissingDmsPanel/ReplyComposer.tsx +63 -0
  173. package/studio/web/components/MissingDmsPanel/index.tsx +98 -0
  174. package/studio/web/components/NotFound.tsx +48 -0
  175. package/studio/web/components/Omni/OmniAvatarPicker.tsx +92 -0
  176. package/studio/web/components/Omni/OmniCards.tsx +202 -0
  177. package/studio/web/components/Omni/OmniGlow.tsx +183 -0
  178. package/studio/web/components/Omni/OmniHistory.tsx +119 -0
  179. package/studio/web/components/Omni/OmniHotword.tsx +40 -0
  180. package/studio/web/components/Omni/OmniMicSelect.tsx +92 -0
  181. package/studio/web/components/Omni/OmniVoiceSample.tsx +81 -0
  182. package/studio/web/components/Omni/OmniWidget.tsx +743 -0
  183. package/studio/web/components/Omni/PendingActions.tsx +200 -0
  184. package/studio/web/components/Omni/Transcript.tsx +127 -0
  185. package/studio/web/components/OmnichatAccountStatus.tsx +87 -0
  186. package/studio/web/components/OmnichatRulePanel/RuleConfig.tsx +150 -0
  187. package/studio/web/components/OmnichatRulePanel/RuleDryRun.tsx +15 -0
  188. package/studio/web/components/OmnichatRulePanel/RuleEnableConfirm.tsx +42 -0
  189. package/studio/web/components/OmnichatRulePanel/RuleSkeleton.tsx +18 -0
  190. package/studio/web/components/OmnichatRulePanel/RuleStatus.tsx +55 -0
  191. package/studio/web/components/OmnichatRulePanel/index.tsx +115 -0
  192. package/studio/web/components/OmnichatRulePanel/rule-steps.ts +67 -0
  193. package/studio/web/components/OmnichatRulePanel/steps/MessageStep.tsx +91 -0
  194. package/studio/web/components/OmnichatRulePanel/steps/RepliesStep.tsx +201 -0
  195. package/studio/web/components/OmnichatRulePanel/steps/TriggerStep.tsx +76 -0
  196. package/studio/web/components/OmnichatRulePanel/steps/types.ts +11 -0
  197. package/studio/web/components/Palette.tsx +17 -0
  198. package/studio/web/components/PlatformCard.tsx +109 -0
  199. package/studio/web/components/PlatformIcon.tsx +19 -0
  200. package/studio/web/components/ProviderCard.tsx +80 -0
  201. package/studio/web/components/ProviderDetail.tsx +367 -0
  202. package/studio/web/components/ProviderLogo.tsx +20 -0
  203. package/studio/web/components/QueuePanel.tsx +77 -0
  204. package/studio/web/components/ReferenceActions.tsx +89 -0
  205. package/studio/web/components/ReferencePicker.tsx +136 -0
  206. package/studio/web/components/ResultMessage.tsx +7 -0
  207. package/studio/web/components/RuleForm.tsx +138 -0
  208. package/studio/web/components/RuleLog.tsx +151 -0
  209. package/studio/web/components/RunningTask.tsx +23 -0
  210. package/studio/web/components/SampleList.tsx +55 -0
  211. package/studio/web/components/SessionPanel.tsx +352 -0
  212. package/studio/web/components/SettingRow.tsx +14 -0
  213. package/studio/web/components/SettingsCategorySkeleton.tsx +25 -0
  214. package/studio/web/components/SidePanel.tsx +77 -0
  215. package/studio/web/components/Sidebar.tsx +289 -0
  216. package/studio/web/components/StageStepper.tsx +87 -0
  217. package/studio/web/components/StudioOffline.tsx +80 -0
  218. package/studio/web/components/TaskName.tsx +17 -0
  219. package/studio/web/components/TopBanners.tsx +30 -0
  220. package/studio/web/components/VideoOmnichatPanel.tsx +287 -0
  221. package/studio/web/components/VoiceAvatar.tsx +33 -0
  222. package/studio/web/components/VoiceCarousel.tsx +196 -0
  223. package/studio/web/components/VoiceCategory.tsx +9 -0
  224. package/studio/web/components/VoiceModal.tsx +39 -0
  225. package/studio/web/components/VoiceProfileCard.tsx +75 -0
  226. package/studio/web/components/VoiceProfilePicker.tsx +107 -0
  227. package/studio/web/components/VoiceProfilesSettings.tsx +150 -0
  228. package/studio/web/components/VoiceProviderCard.tsx +118 -0
  229. package/studio/web/components/VoiceProvidersCard.tsx +26 -0
  230. package/studio/web/components/WaitList.tsx +21 -0
  231. package/studio/web/components/icons.tsx +247 -0
  232. package/studio/web/components/ui/accordion.tsx +47 -0
  233. package/studio/web/components/ui/badge.tsx +18 -0
  234. package/studio/web/components/ui/calendar.tsx +43 -0
  235. package/studio/web/components/ui/chart.tsx +229 -0
  236. package/studio/web/components/ui/checkbox.tsx +26 -0
  237. package/studio/web/components/ui/cx.ts +2 -0
  238. package/studio/web/components/ui/date-time-picker.tsx +84 -0
  239. package/studio/web/components/ui/panel.tsx +23 -0
  240. package/studio/web/components/ui/picker.tsx +58 -0
  241. package/studio/web/components/ui/popover.tsx +24 -0
  242. package/studio/web/components/ui/select.tsx +89 -0
  243. package/studio/web/components/ui/skeleton.tsx +10 -0
  244. package/studio/web/components/ui/slider.tsx +68 -0
  245. package/studio/web/components/ui/switch.tsx +13 -0
  246. package/studio/web/components/ui/tooltip.tsx +59 -0
  247. package/studio/web/hooks/useAccounts.ts +27 -0
  248. package/studio/web/hooks/useAdmin.ts +12 -0
  249. package/studio/web/hooks/useAnalytics.ts +21 -0
  250. package/studio/web/hooks/useAvatarAudioReactive.ts +87 -0
  251. package/studio/web/hooks/useBrowserConnect.ts +48 -0
  252. package/studio/web/hooks/useClonePlan.ts +14 -0
  253. package/studio/web/hooks/useConnected.ts +7 -0
  254. package/studio/web/hooks/useLiveQuery.ts +29 -0
  255. package/studio/web/hooks/useMenuCollapsed.ts +32 -0
  256. package/studio/web/hooks/useMicDevices.ts +53 -0
  257. package/studio/web/hooks/useMissingDms.ts +211 -0
  258. package/studio/web/hooks/useMissingSetupSteps.ts +26 -0
  259. package/studio/web/hooks/useNow.ts +11 -0
  260. package/studio/web/hooks/useOmniAgent.ts +278 -0
  261. package/studio/web/hooks/useOmniClaude.ts +124 -0
  262. package/studio/web/hooks/useOmniDock.ts +142 -0
  263. package/studio/web/hooks/useOmniGreeting.ts +60 -0
  264. package/studio/web/hooks/useOmniHotword.ts +128 -0
  265. package/studio/web/hooks/useOmnichat.ts +26 -0
  266. package/studio/web/hooks/useOnboarding.ts +27 -0
  267. package/studio/web/hooks/usePost.ts +23 -0
  268. package/studio/web/hooks/useQueue.ts +10 -0
  269. package/studio/web/hooks/useReferences.ts +20 -0
  270. package/studio/web/hooks/useRuleEditor.ts +101 -0
  271. package/studio/web/hooks/useRunner.ts +24 -0
  272. package/studio/web/hooks/useSaveAction.ts +23 -0
  273. package/studio/web/hooks/useSchedule.ts +11 -0
  274. package/studio/web/hooks/useServices.ts +10 -0
  275. package/studio/web/hooks/useSessions.ts +15 -0
  276. package/studio/web/hooks/useSettings.ts +11 -0
  277. package/studio/web/hooks/useSettingsSection.ts +30 -0
  278. package/studio/web/hooks/useSidePanel.ts +23 -0
  279. package/studio/web/hooks/useSound.ts +30 -0
  280. package/studio/web/hooks/useUsage.ts +11 -0
  281. package/studio/web/hooks/useVideoVoice.ts +36 -0
  282. package/studio/web/hooks/useVideos.ts +21 -0
  283. package/studio/web/hooks/useVoiceProfilePreview.ts +52 -0
  284. package/studio/web/hooks/useVoiceProfiles.ts +7 -0
  285. package/studio/web/hooks/useVoiceRunning.ts +10 -0
  286. package/studio/web/hooks/useVoiceStudioClosed.ts +8 -0
  287. package/studio/web/index.html +19 -0
  288. package/studio/web/lib/agent.ts +7 -0
  289. package/studio/web/lib/alerts.ts +210 -0
  290. package/studio/web/lib/analytics-metrics.ts +351 -0
  291. package/studio/web/lib/api.ts +70 -0
  292. package/studio/web/lib/audio.ts +39 -0
  293. package/studio/web/lib/avatars.ts +41 -0
  294. package/studio/web/lib/calendar.ts +218 -0
  295. package/studio/web/lib/caption-position.ts +249 -0
  296. package/studio/web/lib/carousel.ts +24 -0
  297. package/studio/web/lib/chime.ts +42 -0
  298. package/studio/web/lib/clone-voice.ts +55 -0
  299. package/studio/web/lib/export.ts +11 -0
  300. package/studio/web/lib/files.ts +8 -0
  301. package/studio/web/lib/format.ts +93 -0
  302. package/studio/web/lib/media.ts +29 -0
  303. package/studio/web/lib/mic-device.ts +72 -0
  304. package/studio/web/lib/missing-dms.ts +70 -0
  305. package/studio/web/lib/omni-cards.ts +42 -0
  306. package/studio/web/lib/omni-cascade.ts +1067 -0
  307. package/studio/web/lib/omni-greeting-audio.ts +24 -0
  308. package/studio/web/lib/omni-hotword.ts +231 -0
  309. package/studio/web/lib/omni-panel.ts +67 -0
  310. package/studio/web/lib/omni-realtime.ts +811 -0
  311. package/studio/web/lib/omni-ui.ts +402 -0
  312. package/studio/web/lib/omni-wait.ts +174 -0
  313. package/studio/web/lib/omnichat-api.ts +44 -0
  314. package/studio/web/lib/orb.ts +270 -0
  315. package/studio/web/lib/pcm-recorder.ts +115 -0
  316. package/studio/web/lib/platforms.ts +17 -0
  317. package/studio/web/lib/providers.ts +27 -0
  318. package/studio/web/lib/publish-draft.ts +52 -0
  319. package/studio/web/lib/publish-settings.ts +81 -0
  320. package/studio/web/lib/query-client.ts +122 -0
  321. package/studio/web/lib/references.ts +13 -0
  322. package/studio/web/lib/services.ts +40 -0
  323. package/studio/web/lib/sessions.ts +103 -0
  324. package/studio/web/lib/settings-categories.ts +58 -0
  325. package/studio/web/lib/sound-audio.ts +33 -0
  326. package/studio/web/lib/sound-timeline.ts +291 -0
  327. package/studio/web/lib/sse.ts +86 -0
  328. package/studio/web/lib/stage-history.ts +141 -0
  329. package/studio/web/lib/stages.ts +344 -0
  330. package/studio/web/lib/studio-offline.ts +60 -0
  331. package/studio/web/lib/upload.ts +24 -0
  332. package/studio/web/lib/video-tabs.ts +118 -0
  333. package/studio/web/lib/voice-profile-draft.ts +34 -0
  334. package/studio/web/lib/voice-profiles.ts +3 -0
  335. package/studio/web/main.tsx +14 -0
  336. package/studio/web/pages/AdminPage/index.tsx +123 -0
  337. package/studio/web/pages/AgentPage/components/Measurements.tsx +102 -0
  338. package/studio/web/pages/AgentPage/index.tsx +28 -0
  339. package/studio/web/pages/AnalyticsPage/components/About.tsx +52 -0
  340. package/studio/web/pages/AnalyticsPage/components/ChannelCards.tsx +106 -0
  341. package/studio/web/pages/AnalyticsPage/components/ChannelView.tsx +137 -0
  342. package/studio/web/pages/AnalyticsPage/components/CollectionStatus.tsx +46 -0
  343. package/studio/web/pages/AnalyticsPage/components/Comparison.tsx +68 -0
  344. package/studio/web/pages/AnalyticsPage/components/Diagnosis.tsx +50 -0
  345. package/studio/web/pages/AnalyticsPage/components/Highlights.tsx +49 -0
  346. package/studio/web/pages/AnalyticsPage/components/Hint.tsx +11 -0
  347. package/studio/web/pages/AnalyticsPage/components/HookVsBody.tsx +100 -0
  348. package/studio/web/pages/AnalyticsPage/components/HourlyWave.tsx +133 -0
  349. package/studio/web/pages/AnalyticsPage/components/Insights.tsx +70 -0
  350. package/studio/web/pages/AnalyticsPage/components/MetricCard.tsx +46 -0
  351. package/studio/web/pages/AnalyticsPage/components/PlanSection.tsx +40 -0
  352. package/studio/web/pages/AnalyticsPage/components/ProgressionChart.tsx +155 -0
  353. package/studio/web/pages/AnalyticsPage/components/ReelBarsChart.tsx +154 -0
  354. package/studio/web/pages/AnalyticsPage/components/ReelThumb.tsx +19 -0
  355. package/studio/web/pages/AnalyticsPage/components/ReelView.tsx +290 -0
  356. package/studio/web/pages/AnalyticsPage/components/ReelsTable.tsx +269 -0
  357. package/studio/web/pages/AnalyticsPage/components/RequestWithFocus.tsx +38 -0
  358. package/studio/web/pages/AnalyticsPage/components/SegmentedControl.tsx +35 -0
  359. package/studio/web/pages/AnalyticsPage/components/VsMedian.tsx +31 -0
  360. package/studio/web/pages/AnalyticsPage/index.tsx +17 -0
  361. package/studio/web/pages/GettingStartedPage/index.tsx +174 -0
  362. package/studio/web/pages/NewVideoPage/components/DeliverablesPicker.tsx +62 -0
  363. package/studio/web/pages/NewVideoPage/components/FormatPicker.tsx +33 -0
  364. package/studio/web/pages/NewVideoPage/index.tsx +133 -0
  365. package/studio/web/pages/NotFoundPage/index.tsx +9 -0
  366. package/studio/web/pages/OmnichatPage/components/NewFromLink.tsx +59 -0
  367. package/studio/web/pages/OmnichatPage/components/RuleListItem.tsx +64 -0
  368. package/studio/web/pages/OmnichatPage/components/RulePreview.tsx +14 -0
  369. package/studio/web/pages/OmnichatPage/components/RuleView.tsx +88 -0
  370. package/studio/web/pages/OmnichatPage/index.tsx +153 -0
  371. package/studio/web/pages/QueuePage/index.tsx +22 -0
  372. package/studio/web/pages/ReferencePage/components/ReferenceTexts.tsx +131 -0
  373. package/studio/web/pages/ReferencePage/index.tsx +216 -0
  374. package/studio/web/pages/ReferencesPage/components/NewReferenceForm.tsx +150 -0
  375. package/studio/web/pages/ReferencesPage/components/ReferenceCard.tsx +42 -0
  376. package/studio/web/pages/ReferencesPage/components/ReferenceGridSkeleton.tsx +31 -0
  377. package/studio/web/pages/ReferencesPage/index.tsx +67 -0
  378. package/studio/web/pages/SchedulePage/components/CalendarSkeleton.tsx +18 -0
  379. package/studio/web/pages/SchedulePage/components/DayView.tsx +177 -0
  380. package/studio/web/pages/SchedulePage/components/HourPicker.tsx +103 -0
  381. package/studio/web/pages/SchedulePage/components/MonthDayCell.tsx +111 -0
  382. package/studio/web/pages/SchedulePage/components/MonthView.tsx +47 -0
  383. package/studio/web/pages/SchedulePage/components/PlatformIcons.tsx +12 -0
  384. package/studio/web/pages/SchedulePage/components/PostThumb.tsx +15 -0
  385. package/studio/web/pages/SchedulePage/components/WeekColumn.tsx +75 -0
  386. package/studio/web/pages/SchedulePage/components/WeekView.tsx +35 -0
  387. package/studio/web/pages/SchedulePage/index.tsx +285 -0
  388. package/studio/web/pages/SettingsPage/components/AgentAccounts.tsx +263 -0
  389. package/studio/web/pages/SettingsPage/components/AgentCategory.tsx +20 -0
  390. package/studio/web/pages/SettingsPage/components/ModelPicker.tsx +49 -0
  391. package/studio/web/pages/SettingsPage/components/ModelsSettings.tsx +138 -0
  392. package/studio/web/pages/SettingsPage/components/PlatformsCategory.tsx +23 -0
  393. package/studio/web/pages/SettingsPage/components/PublishingCategory/AccountStatus.tsx +29 -0
  394. package/studio/web/pages/SettingsPage/components/PublishingCategory/AutoPublishPanel.tsx +90 -0
  395. package/studio/web/pages/SettingsPage/components/PublishingCategory/BrowserConnect.tsx +62 -0
  396. package/studio/web/pages/SettingsPage/components/PublishingCategory/CheckField.tsx +19 -0
  397. package/studio/web/pages/SettingsPage/components/PublishingCategory/InstagramPanel.tsx +113 -0
  398. package/studio/web/pages/SettingsPage/components/PublishingCategory/OmnichatSettingsPanel.tsx +146 -0
  399. package/studio/web/pages/SettingsPage/components/PublishingCategory/SaveBar.tsx +33 -0
  400. package/studio/web/pages/SettingsPage/components/PublishingCategory/SchedulePanel.tsx +132 -0
  401. package/studio/web/pages/SettingsPage/components/PublishingCategory/SettingsCard.tsx +47 -0
  402. package/studio/web/pages/SettingsPage/components/PublishingCategory/SettingsCardGroup.tsx +19 -0
  403. package/studio/web/pages/SettingsPage/components/PublishingCategory/TextPanel.tsx +55 -0
  404. package/studio/web/pages/SettingsPage/components/PublishingCategory/TiktokPanel.tsx +123 -0
  405. package/studio/web/pages/SettingsPage/components/PublishingCategory/YoutubePanel.tsx +100 -0
  406. package/studio/web/pages/SettingsPage/components/PublishingCategory/badges.tsx +15 -0
  407. package/studio/web/pages/SettingsPage/components/PublishingCategory/index.tsx +40 -0
  408. package/studio/web/pages/SettingsPage/components/PublishingCategory/types.ts +10 -0
  409. package/studio/web/pages/SettingsPage/components/UsageCategory.tsx +389 -0
  410. package/studio/web/pages/SettingsPage/components/VoiceProvidersCategory.tsx +97 -0
  411. package/studio/web/pages/SettingsPage/index.tsx +85 -0
  412. package/studio/web/pages/VideoPage/components/AudioPlayer.tsx +113 -0
  413. package/studio/web/pages/VideoPage/components/CaptionPositionEditor/AxisField.tsx +50 -0
  414. package/studio/web/pages/VideoPage/components/CaptionPositionEditor/CaptionList.tsx +56 -0
  415. package/studio/web/pages/VideoPage/components/CaptionPositionEditor/CaptionListItem.tsx +92 -0
  416. package/studio/web/pages/VideoPage/components/CaptionPositionEditor/CaptionMargins.tsx +57 -0
  417. package/studio/web/pages/VideoPage/components/CaptionPositionEditor/CaptionStyleFields.tsx +70 -0
  418. package/studio/web/pages/VideoPage/components/CaptionPositionEditor/CaptionTextForm.tsx +87 -0
  419. package/studio/web/pages/VideoPage/components/CaptionPositionEditor/ColorField.tsx +37 -0
  420. package/studio/web/pages/VideoPage/components/CaptionPositionEditor/MarginField.tsx +59 -0
  421. package/studio/web/pages/VideoPage/components/CaptionPositionEditor/NumberField.tsx +57 -0
  422. package/studio/web/pages/VideoPage/components/CaptionPositionEditor/PositionControls.tsx +140 -0
  423. package/studio/web/pages/VideoPage/components/CaptionPositionEditor/captionEditor.ts +215 -0
  424. package/studio/web/pages/VideoPage/components/CaptionPositionEditor/useCaptionPosition.ts +154 -0
  425. package/studio/web/pages/VideoPage/components/CarouselChangeRequest.tsx +72 -0
  426. package/studio/web/pages/VideoPage/components/CarouselCompositionStage.tsx +80 -0
  427. package/studio/web/pages/VideoPage/components/CarouselCreate.tsx +63 -0
  428. package/studio/web/pages/VideoPage/components/CarouselMissing.tsx +19 -0
  429. package/studio/web/pages/VideoPage/components/CarouselPublishStage.tsx +173 -0
  430. package/studio/web/pages/VideoPage/components/CarouselScriptStage.tsx +82 -0
  431. package/studio/web/pages/VideoPage/components/CarouselSlideCard.tsx +49 -0
  432. package/studio/web/pages/VideoPage/components/CarouselViewer.tsx +55 -0
  433. package/studio/web/pages/VideoPage/components/ChangedVoices.tsx +53 -0
  434. package/studio/web/pages/VideoPage/components/CompositionStage.tsx +78 -0
  435. package/studio/web/pages/VideoPage/components/CoverPicker.tsx +32 -0
  436. package/studio/web/pages/VideoPage/components/EditorStage.tsx +53 -0
  437. package/studio/web/pages/VideoPage/components/LineTakes.tsx +160 -0
  438. package/studio/web/pages/VideoPage/components/LineTextEditor.tsx +124 -0
  439. package/studio/web/pages/VideoPage/components/LineVoicePicker.tsx +37 -0
  440. package/studio/web/pages/VideoPage/components/MediaImage.tsx +25 -0
  441. package/studio/web/pages/VideoPage/components/MotionScriptStage.tsx +39 -0
  442. package/studio/web/pages/VideoPage/components/NarrationStage.tsx +70 -0
  443. package/studio/web/pages/VideoPage/components/PublishForm.tsx +387 -0
  444. package/studio/web/pages/VideoPage/components/PublishStage.tsx +76 -0
  445. package/studio/web/pages/VideoPage/components/PublishText.tsx +149 -0
  446. package/studio/web/pages/VideoPage/components/ScenesApprovalPanel.tsx +278 -0
  447. package/studio/web/pages/VideoPage/components/ScheduledInfo.tsx +72 -0
  448. package/studio/web/pages/VideoPage/components/ScriptApprovalPanel.tsx +189 -0
  449. package/studio/web/pages/VideoPage/components/ScriptStage.tsx +17 -0
  450. package/studio/web/pages/VideoPage/components/SequencePlayer.tsx +146 -0
  451. package/studio/web/pages/VideoPage/components/SoundTimeline/AddSound.tsx +117 -0
  452. package/studio/web/pages/VideoPage/components/SoundTimeline/AreaSelection.tsx +74 -0
  453. package/studio/web/pages/VideoPage/components/SoundTimeline/BedPanel.tsx +94 -0
  454. package/studio/web/pages/VideoPage/components/SoundTimeline/BedRow.tsx +45 -0
  455. package/studio/web/pages/VideoPage/components/SoundTimeline/CaptionOverlay.tsx +44 -0
  456. package/studio/web/pages/VideoPage/components/SoundTimeline/CaptionSaveBar.tsx +32 -0
  457. package/studio/web/pages/VideoPage/components/SoundTimeline/CaptionsPanel.tsx +22 -0
  458. package/studio/web/pages/VideoPage/components/SoundTimeline/ClipBlock.tsx +111 -0
  459. package/studio/web/pages/VideoPage/components/SoundTimeline/ClipPanel.tsx +76 -0
  460. package/studio/web/pages/VideoPage/components/SoundTimeline/CutsPanel.tsx +44 -0
  461. package/studio/web/pages/VideoPage/components/SoundTimeline/EditorTabBar.tsx +73 -0
  462. package/studio/web/pages/VideoPage/components/SoundTimeline/EffectChoice.tsx +46 -0
  463. package/studio/web/pages/VideoPage/components/SoundTimeline/EffectItem.tsx +89 -0
  464. package/studio/web/pages/VideoPage/components/SoundTimeline/EffectPanel.tsx +232 -0
  465. package/studio/web/pages/VideoPage/components/SoundTimeline/EffectsRow.tsx +21 -0
  466. package/studio/web/pages/VideoPage/components/SoundTimeline/Lane.tsx +39 -0
  467. package/studio/web/pages/VideoPage/components/SoundTimeline/MarkerRow.tsx +40 -0
  468. package/studio/web/pages/VideoPage/components/SoundTimeline/MuteButton.tsx +36 -0
  469. package/studio/web/pages/VideoPage/components/SoundTimeline/NumberInput.tsx +37 -0
  470. package/studio/web/pages/VideoPage/components/SoundTimeline/PlanWarnings.tsx +19 -0
  471. package/studio/web/pages/VideoPage/components/SoundTimeline/PlayerResizeHandle.tsx +50 -0
  472. package/studio/web/pages/VideoPage/components/SoundTimeline/PreviewControls.tsx +51 -0
  473. package/studio/web/pages/VideoPage/components/SoundTimeline/SceneRow.tsx +54 -0
  474. package/studio/web/pages/VideoPage/components/SoundTimeline/SettingsPanel.tsx +65 -0
  475. package/studio/web/pages/VideoPage/components/SoundTimeline/SoundLibrary.tsx +99 -0
  476. package/studio/web/pages/VideoPage/components/SoundTimeline/SoundPreview.tsx +82 -0
  477. package/studio/web/pages/VideoPage/components/SoundTimeline/SpeedPanel.tsx +128 -0
  478. package/studio/web/pages/VideoPage/components/SoundTimeline/TimelineDock.tsx +56 -0
  479. package/studio/web/pages/VideoPage/components/SoundTimeline/TimelineFooter.tsx +26 -0
  480. package/studio/web/pages/VideoPage/components/SoundTimeline/TimelineSkeleton.tsx +21 -0
  481. package/studio/web/pages/VideoPage/components/SoundTimeline/TimelineToolbar.tsx +160 -0
  482. package/studio/web/pages/VideoPage/components/SoundTimeline/TimelineTracks.tsx +121 -0
  483. package/studio/web/pages/VideoPage/components/SoundTimeline/TrackSidebar.tsx +30 -0
  484. package/studio/web/pages/VideoPage/components/SoundTimeline/VoiceRow.tsx +53 -0
  485. package/studio/web/pages/VideoPage/components/SoundTimeline/Waveform.tsx +97 -0
  486. package/studio/web/pages/VideoPage/components/SoundTimeline/clipActions.ts +50 -0
  487. package/studio/web/pages/VideoPage/components/SoundTimeline/effectActions.ts +161 -0
  488. package/studio/web/pages/VideoPage/components/SoundTimeline/index.tsx +161 -0
  489. package/studio/web/pages/VideoPage/components/SoundTimeline/timelineGeometry.ts +45 -0
  490. package/studio/web/pages/VideoPage/components/SoundTimeline/types.ts +43 -0
  491. package/studio/web/pages/VideoPage/components/SoundTimeline/useAutosave.ts +107 -0
  492. package/studio/web/pages/VideoPage/components/SoundTimeline/useEditorShortcuts.ts +168 -0
  493. package/studio/web/pages/VideoPage/components/SoundTimeline/useFitHeight.ts +40 -0
  494. package/studio/web/pages/VideoPage/components/SoundTimeline/useFollowPlayhead.ts +21 -0
  495. package/studio/web/pages/VideoPage/components/SoundTimeline/useListenMix.ts +43 -0
  496. package/studio/web/pages/VideoPage/components/SoundTimeline/usePlayback.ts +231 -0
  497. package/studio/web/pages/VideoPage/components/SoundTimeline/usePlayerWidth.ts +27 -0
  498. package/studio/web/pages/VideoPage/components/SoundTimeline/useSoundData.ts +124 -0
  499. package/studio/web/pages/VideoPage/components/SoundTimeline/useSoundTimeline.ts +511 -0
  500. package/studio/web/pages/VideoPage/components/StageDrawer/CompareVersions.tsx +111 -0
  501. package/studio/web/pages/VideoPage/components/StageDrawer/VersionCard.tsx +81 -0
  502. package/studio/web/pages/VideoPage/components/StageDrawer/VersionMedia.tsx +24 -0
  503. package/studio/web/pages/VideoPage/components/StageDrawer/VersionText.tsx +15 -0
  504. package/studio/web/pages/VideoPage/components/StageDrawer/index.tsx +109 -0
  505. package/studio/web/pages/VideoPage/components/StageProcess.tsx +126 -0
  506. package/studio/web/pages/VideoPage/components/StageResult.tsx +69 -0
  507. package/studio/web/pages/VideoPage/components/SwapReference.tsx +92 -0
  508. package/studio/web/pages/VideoPage/components/ThumbnailPanel.tsx +252 -0
  509. package/studio/web/pages/VideoPage/components/TimingPanel.tsx +89 -0
  510. package/studio/web/pages/VideoPage/components/VideoPlayer.tsx +18 -0
  511. package/studio/web/pages/VideoPage/components/VideoSkeleton.tsx +50 -0
  512. package/studio/web/pages/VideoPage/components/VideoVoiceInfo.tsx +33 -0
  513. package/studio/web/pages/VideoPage/components/VideoVoicePicker.tsx +165 -0
  514. package/studio/web/pages/VideoPage/components/VoicePauseBanner.tsx +56 -0
  515. package/studio/web/pages/VideoPage/components/WithLogo.tsx +10 -0
  516. package/studio/web/pages/VideoPage/index.tsx +295 -0
  517. package/studio/web/pages/VideosPage/components/PublishedOn.tsx +85 -0
  518. package/studio/web/pages/VideosPage/components/ServicesPanel.tsx +81 -0
  519. package/studio/web/pages/VideosPage/components/SessionsPanel.tsx +62 -0
  520. package/studio/web/pages/VideosPage/components/VideoListSkeleton.tsx +25 -0
  521. package/studio/web/pages/VideosPage/components/VideoRow.tsx +122 -0
  522. package/studio/web/pages/VideosPage/index.tsx +134 -0
  523. package/studio/web/pages/VoicePage/index.tsx +28 -0
  524. package/studio/web/router.tsx +201 -0
  525. package/studio/web/styles/base/elements.css +55 -0
  526. package/studio/web/styles/base/utilities.css +25 -0
  527. package/studio/web/styles/components/badge.css +33 -0
  528. package/studio/web/styles/components/claude-running.css +27 -0
  529. package/studio/web/styles/components/claude-session.css +212 -0
  530. package/studio/web/styles/components/claude-walking.css +92 -0
  531. package/studio/web/styles/components/clone-voice-save.css +136 -0
  532. package/studio/web/styles/components/clone-voice.css +379 -0
  533. package/studio/web/styles/components/controls.css +70 -0
  534. package/studio/web/styles/components/data-and-image.css +54 -0
  535. package/studio/web/styles/components/forms.css +132 -0
  536. package/studio/web/styles/components/human-banner.css +99 -0
  537. package/studio/web/styles/components/lightbox.css +50 -0
  538. package/studio/web/styles/components/markdown.css +68 -0
  539. package/studio/web/styles/components/modal.css +70 -0
  540. package/studio/web/styles/components/omni-cards.css +105 -0
  541. package/studio/web/styles/components/omni.css +859 -0
  542. package/studio/web/styles/components/omnichat.css +538 -0
  543. package/studio/web/styles/components/panel.css +115 -0
  544. package/studio/web/styles/components/platform-card.css +140 -0
  545. package/studio/web/styles/components/provider-card.css +132 -0
  546. package/studio/web/styles/components/queue.css +185 -0
  547. package/studio/web/styles/components/reference-actions.css +133 -0
  548. package/studio/web/styles/components/reference-picker.css +146 -0
  549. package/studio/web/styles/components/results-drawer.css +224 -0
  550. package/studio/web/styles/components/segmented.css +115 -0
  551. package/studio/web/styles/components/side-panel-tab.css +23 -0
  552. package/studio/web/styles/components/status-signals.css +66 -0
  553. package/studio/web/styles/components/stepper.css +208 -0
  554. package/studio/web/styles/components/table.css +19 -0
  555. package/studio/web/styles/components/tabs.css +24 -0
  556. package/studio/web/styles/components/ui/accordion.css +71 -0
  557. package/studio/web/styles/components/ui/chart.css +162 -0
  558. package/studio/web/styles/components/ui/checkbox.css +37 -0
  559. package/studio/web/styles/components/ui/date-picker.css +153 -0
  560. package/studio/web/styles/components/ui/select.css +110 -0
  561. package/studio/web/styles/components/ui/skeleton.css +25 -0
  562. package/studio/web/styles/components/ui/slider.css +81 -0
  563. package/studio/web/styles/components/ui/switch.css +41 -0
  564. package/studio/web/styles/components/ui/tooltip.css +24 -0
  565. package/studio/web/styles/components/voice-carousel.css +255 -0
  566. package/studio/web/styles/components/voice-editor.css +179 -0
  567. package/studio/web/styles/components/voice-grid.css +56 -0
  568. package/studio/web/styles/components/voice-profiles.css +192 -0
  569. package/studio/web/styles/design-system.md +136 -0
  570. package/studio/web/styles/index.css +116 -0
  571. package/studio/web/styles/layout/page.css +41 -0
  572. package/studio/web/styles/layout/sidebar-groups.css +39 -0
  573. package/studio/web/styles/layout/sidebar.css +111 -0
  574. package/studio/web/styles/pages/agent.css +26 -0
  575. package/studio/web/styles/pages/analytics/dashboard.css +614 -0
  576. package/studio/web/styles/pages/analytics/insights.css +48 -0
  577. package/studio/web/styles/pages/getting-started.css +166 -0
  578. package/studio/web/styles/pages/new-video/form.css +32 -0
  579. package/studio/web/styles/pages/not-found.css +59 -0
  580. package/studio/web/styles/pages/references.css +204 -0
  581. package/studio/web/styles/pages/schedule/calendar.css +541 -0
  582. package/studio/web/styles/pages/schedule/chip-states.css +60 -0
  583. package/studio/web/styles/pages/settings/models.css +72 -0
  584. package/studio/web/styles/pages/settings/shell.css +361 -0
  585. package/studio/web/styles/pages/settings/usage.css +63 -0
  586. package/studio/web/styles/pages/studio-offline.css +105 -0
  587. package/studio/web/styles/pages/video/caption-controls.css +194 -0
  588. package/studio/web/styles/pages/video/captions-grid.css +70 -0
  589. package/studio/web/styles/pages/video/captions.css +79 -0
  590. package/studio/web/styles/pages/video/editor-chat.css +103 -0
  591. package/studio/web/styles/pages/video/export.css +50 -0
  592. package/studio/web/styles/pages/video/frames.css +29 -0
  593. package/studio/web/styles/pages/video/fullscreen-stage.css +400 -0
  594. package/studio/web/styles/pages/video/instagram-carousel.css +309 -0
  595. package/studio/web/styles/pages/video/layout.css +285 -0
  596. package/studio/web/styles/pages/video/narration.css +138 -0
  597. package/studio/web/styles/pages/video/preview-overview.css +11 -0
  598. package/studio/web/styles/pages/video/processing-notice.css +34 -0
  599. package/studio/web/styles/pages/video/publish-when.css +76 -0
  600. package/studio/web/styles/pages/video/publish.css +97 -0
  601. package/studio/web/styles/pages/video/result.css +96 -0
  602. package/studio/web/styles/pages/video/scenes.css +167 -0
  603. package/studio/web/styles/pages/video/sound-timeline.css +672 -0
  604. package/studio/web/styles/pages/video/sound-toolbar.css +149 -0
  605. package/studio/web/styles/pages/video/take-player.css +100 -0
  606. package/studio/web/styles/pages/video/thumbnail.css +157 -0
  607. package/studio/web/styles/pages/video/timeline.css +117 -0
  608. package/studio/web/styles/pages/video/voice-picker.css +14 -0
  609. package/studio/web/styles/pages/video-list/active-row.css +27 -0
  610. package/studio/web/styles/pages/video-list/card-link.css +49 -0
  611. package/studio/web/styles/pages/video-list/list.css +86 -0
  612. package/studio/web/styles/theme/identity/details.css +204 -0
  613. package/studio/web/styles/theme/identity/shell.css +249 -0
  614. package/studio/web/styles/theme/identity/surfaces.css +183 -0
  615. package/studio/web/styles/theme/identity/titles-and-buttons.css +120 -0
  616. package/studio/web/styles/theme/no-borders/blocks.css +208 -0
  617. package/studio/web/styles/theme/no-borders/controls.css +256 -0
  618. package/studio/web/styles/tokens.css +245 -0
  619. package/studio/web/types/api.ts +2 -0
  620. package/studio/web/types/assets.d.ts +9 -0
  621. package/templates/brief.md +27 -0
  622. package/templates/captions.html +204 -0
  623. package/templates/carousel-composition.md +47 -0
  624. package/templates/carousel-script.md +60 -0
  625. package/templates/components/voiceavatar/README.md +87 -0
  626. package/templates/components/voiceavatar/demo.html +50 -0
  627. package/templates/components/voiceavatar/voiceavatar.js +415 -0
  628. package/templates/motion.md +80 -0
  629. package/templates/playbook.md +43 -0
  630. package/templates/reference.md +123 -0
  631. package/templates/scene.html +112 -0
  632. package/templates/scenes.example.json +96 -0
  633. package/templates/script.md +63 -0
  634. package/templates/slide.html +71 -0
  635. package/templates/wireframe.html +91 -0
  636. package/tools/analysis.mjs +300 -0
  637. package/tools/approval.mjs +442 -0
  638. package/tools/assemble.mjs +942 -0
  639. package/tools/build-motion.mjs +437 -0
  640. package/tools/captions.mjs +246 -0
  641. package/tools/carousel.mjs +558 -0
  642. package/tools/compose.mjs +307 -0
  643. package/tools/cuts.mjs +74 -0
  644. package/tools/editor.mjs +419 -0
  645. package/tools/epidemic-capture.js +54 -0
  646. package/tools/fanout.mjs +69 -0
  647. package/tools/history.mjs +70 -0
  648. package/tools/history.py +61 -0
  649. package/tools/job-queue.mjs +180 -0
  650. package/tools/job_queue.py +430 -0
  651. package/tools/lib/agents.mjs +412 -0
  652. package/tools/lib/channel.mjs +399 -0
  653. package/tools/lib/claude-settings.mjs +67 -0
  654. package/tools/lib/cli.mjs +9 -0
  655. package/tools/lib/fanout.mjs +234 -0
  656. package/tools/lib/http.mjs +94 -0
  657. package/tools/lib/index.js +14 -0
  658. package/tools/lib/mcp-server.mjs +69 -0
  659. package/tools/lib/metrics/metrics.mjs +204 -0
  660. package/tools/lib/narration.mjs +15 -0
  661. package/tools/lib/oauth-client.mjs +295 -0
  662. package/tools/lib/omnichat/config.mjs +139 -0
  663. package/tools/lib/omnichat/errors.mjs +42 -0
  664. package/tools/lib/omnichat/extract.mjs +82 -0
  665. package/tools/lib/omnichat/gate.mjs +151 -0
  666. package/tools/lib/omnichat/manual.mjs +166 -0
  667. package/tools/lib/omnichat/match.mjs +78 -0
  668. package/tools/lib/omnichat/pass.mjs +393 -0
  669. package/tools/lib/omnichat/record.mjs +208 -0
  670. package/tools/lib/omnichat/text.mjs +315 -0
  671. package/tools/lib/omnichat/webhook.mjs +166 -0
  672. package/tools/lib/pairing.mjs +99 -0
  673. package/tools/lib/paths.mjs +41 -0
  674. package/tools/lib/platforms/instagram.mjs +469 -0
  675. package/tools/lib/platforms/tiktok.mjs +343 -0
  676. package/tools/lib/platforms/youtube.mjs +228 -0
  677. package/tools/lib/publish/publish.mjs +244 -0
  678. package/tools/lib/schedule/schedule.mjs +191 -0
  679. package/tools/lib/service.mjs +134 -0
  680. package/tools/lib/skill-client.mjs +126 -0
  681. package/tools/lib/skills-release.mjs +196 -0
  682. package/tools/lib/testing.mjs +52 -0
  683. package/tools/lib/up.mjs +187 -0
  684. package/tools/metrics.mjs +415 -0
  685. package/tools/models.mjs +134 -0
  686. package/tools/narrate.mjs +257 -0
  687. package/tools/omni.mjs +298 -0
  688. package/tools/omni_paths.py +12 -0
  689. package/tools/omnichat.mjs +1577 -0
  690. package/tools/omnivideo.py +1122 -0
  691. package/tools/package.mjs +130 -0
  692. package/tools/platforms.mjs +236 -0
  693. package/tools/premiere.mjs +533 -0
  694. package/tools/publish.mjs +1496 -0
  695. package/tools/reference.mjs +1408 -0
  696. package/tools/render.mjs +426 -0
  697. package/tools/runner.mjs +156 -0
  698. package/tools/setup.mjs +466 -0
  699. package/tools/skills-release.mjs +93 -0
  700. package/tools/sound.mjs +1323 -0
  701. package/tools/soundtrack.mjs +217 -0
  702. package/tools/thumbnail.mjs +271 -0
  703. package/tools/timing.mjs +108 -0
  704. package/tools/usage.py +628 -0
  705. package/tools/voice-envelope.mjs +185 -0
  706. package/tools/voice.py +448 -0
  707. package/tools/voice_sample.py +75 -0
  708. package/tools/wireframe.mjs +230 -0
@@ -0,0 +1,1577 @@
1
+ #!/usr/bin/env node
2
+ /* ============================================================================
3
+ Omnichat on Instagram comments, like ManyChat: whoever comments the keyword
4
+ on the post (Reel, carousel or photo) gets a DM with the link (the comment's
5
+ private reply) and a public reply on the comment ("te mandei no direct").
6
+
7
+ Each target is a dir with publication.json (the media id on Instagram):
8
+ videos/<slug> the video's Reel
9
+ videos/<slug>/carousel the carousel of the same content (tools/carousel.mjs)
10
+ .omnichat/posts/<code> a post that did not come from omnivideo (Reel, carousel or photo)
11
+
12
+ node tools/omnichat.mjs status [--json] token, permissions, agent and rules
13
+ node tools/omnichat.mjs rule videos/<slug> [options] creates or changes the video's rule
14
+ --keyword QUERO triggers on this word or phrase (repeat for more than one);
15
+ case, accents and punctuation don't count
16
+ --any any comment triggers (--keywords to go back)
17
+ --no-generic only the keyword; by default "quero", "me manda" and the like
18
+ also trigger, with Claude reading the context when the request
19
+ comes mid-sentence (--generic to go back)
20
+ --offer "text" what the DM delivers, in words, for Claude to read the context
21
+ (without it, the post caption and the link are used)
22
+ --link https://… what the DM delivers
23
+ --dm "text" the DM; {username}, {link} and {post} are replaced (without {link}, it goes at the end)
24
+ --quote-post the commented post's link on the DM's first line (without {post} in the text):
25
+ Instagram shows the post card on top and the message reads as a reply
26
+ to it (--no-quote-post to remove; a new rule follows omnichat.instagram.quote_post)
27
+ --public-reply "text" public reply, one picked at random per comment (repeat for
28
+ more than one); --no-public-reply replies only by DM
29
+ --button the DM opens with a button ("Quero o link") and the link goes out when the person taps it
30
+ (needs the webhook: node tools/omnichat.mjs webhook setup); --no-button to remove
31
+ --require-follow the tap only delivers if the person follows the account; otherwise they get
32
+ "follow and tap again" with a new button (implies --button; --no-require-follow)
33
+ --opening-dm "text" the text above the button ({username} is replaced)
34
+ --button-label "text" the button's label, up to 20 characters
35
+ --not-following-dm "text" what goes out when someone taps without following
36
+ --retry-label "text" the button of that message, up to 20 characters ("Pronto, já sigo!")
37
+ --no-follow-skip whoever taps "já sigo" again without following gets no "Receber sem seguir"
38
+ (on by default with --require-follow; --follow-skip to go back)
39
+ --skip-label "text" that button's label, up to 20 characters ("Receber sem seguir")
40
+ --skip-dm "text" the DM of whoever gets the link through that button ({username} and {link} are replaced)
41
+ --link-button the link goes in a button that opens it ("Abrir link"), not in the DM's text
42
+ ({link} leaves the text); --no-link-button to go back
43
+ --link-label "text" that button's label, up to 20 characters
44
+ --answered skip|send comment the account already replied to by hand: skip (default) or send
45
+ --answered-if "text" with send, only when your reply says this (repeat for more)
46
+ --json '{…}' the same fields at once (the studio uses it; also generic_phrases, offer, quote_post, already_replied, already_replied_if)
47
+ node tools/omnichat.mjs post <post link> [rule options] a post that is already live (Reel,
48
+ carousel or photo, a /reel/ or /p/ link) and did not come from omnivideo: creates
49
+ .omnichat/posts/<code>/, which works where videos/<slug> does; an omnivideo post uses its own dir
50
+ node tools/omnichat.mjs enable videos/<slug> --confirm starts replying
51
+ node tools/omnichat.mjs disable videos/<slug> also without a rule: it is the "don't enable on your own at publish"
52
+ node tools/omnichat.mjs published videos/<slug>[/carousel] [--plan] publish.mjs calls it when the Reel
53
+ (or the carousel) goes up: with omnichat.instagram.enable_on_publish, enables the rule (without a
54
+ rule, it is born from the narration's or the script's "comenta X que eu te mando" and the link
55
+ found in the content)
56
+ node tools/omnichat.mjs test videos/<slug> "comment" triggers? what would it send? (no API)
57
+ node tools/omnichat.mjs run [videos/<slug>] [--dry-run] one pass (the agent calls it every minute)
58
+ --dry-run reads the comments and shows what it would do, without sending or writing anything
59
+ node tools/omnichat.mjs log videos/<slug> [--json]
60
+ node tools/omnichat.mjs retry videos/<slug> what failed without a DM goes back to the queue (within the 7 days)
61
+ node tools/omnichat.mjs comments videos/<slug> [--json] fetches the post now and lists who did not
62
+ get the DM: what the rule skipped, has not picked up yet or does not trigger, and what failed
63
+ node tools/omnichat.mjs delete videos/<slug> <id> [<id>…] --confirm deletes comments on the post (people's or
64
+ the account's own replies) on Instagram: it cannot be undone. A reply that was waiting in the queue is skipped.
65
+ node tools/omnichat.mjs send videos/<slug> <id> [<id>…] --confirm the rule's DM to the chosen
66
+ comments, by hand, even the ones that did not trigger (up to max_per_pass at a time)
67
+ --no-public-reply only the DM, without the reply on the comment
68
+ --public-reply "text" the reply on the comment written by you, instead of the rule's
69
+ random ones ({username} is replaced)
70
+ --public-reply-only only the reply on the comment, without the DM: works past the 7 days
71
+ and without a rule (then with --public-reply)
72
+ node tools/omnichat.mjs webhook setup | subscribe | status | serve [--port 5434]
73
+ the messaging webhook that the button and the follow gate need: `setup` creates the verify token and says
74
+ what to paste in the Meta panel, `subscribe` signs the account up to messages and postbacks, `serve` listens
75
+ (behind a tunnel: cloudflared tunnel --url http://127.0.0.1:5434)
76
+ node tools/omnichat.mjs cloud status | push [videos/<slug>] [--records] [--force] | pull videos/<slug>
77
+ the Worker in cloud/omnichat (cloud.url in the config, OMNICHAT_CLOUD_SECRET in the .env):
78
+ `push` sends the config, the Instagram account and the rules (with --records, the local records too,
79
+ once: --force overwrites one the cloud already wrote); with cloud.omnichat the cloud answers, every
80
+ saved rule goes up on its own, the record is read from there and the local agent and webhook step aside
81
+ node tools/omnichat.mjs agent [--json] | install | uninstall
82
+
83
+ Replying goes outside, on behalf of the account: the rule is born disabled and only
84
+ enables at the user's request (the studio switch, or `enable --confirm`) or, when the post
85
+ goes up, with omnichat.instagram.enable_on_publish, the config's permanent approval. The
86
+ launchd agent only reads and replies to targets with the rule enabled and the post published.
87
+
88
+ Comments go by polling (phase 1); only the button's tap needs the webhook (phase 2, below). The comments
89
+ webhook needs a Live app, Advanced Access and
90
+ Business Verification, so the agent polls the API every minute: it reads the
91
+ comments_count of each post with an enabled rule and only fetches comments when
92
+ it changes. Older posts are checked less often (omnichat.instagram.interval_min).
93
+ Meta allows one private reply per comment, up to 7 days after it: with the Mac
94
+ asleep, the agent replies when it wakes, within that deadline.
95
+
96
+ The DM goes first. Only once it is delivered does the public reply go: without a DM,
97
+ "te mandei no direct" would be a lie. Meta rate limits or blocks stop everything for
98
+ a while (rate_limit_pause_min); a transient error retries on the next pass, up to
99
+ `attempts` times.
100
+
101
+ Files (in each target; below, the video's): videos/<slug>/omnichat.json is the rule (you, via the studio or via
102
+ `rule`); videos/<slug>/omnichat-record.json is what this tool has done
103
+ (only it writes). Agent lock, pause and log in .omnichat/.
104
+ Token: the same as publish.mjs, with instagram_business_manage_comments and
105
+ instagram_business_manage_messages on top of the publishing ones (reference/omnichat.md).
106
+ ============================================================================ */
107
+
108
+ /* The decisions (matching, the pass, the texts, sending by hand) live in tools/lib/omnichat/ and the
109
+ Instagram calls in tools/lib/platforms/instagram.mjs; this file is the CLI: files, flags, the lock,
110
+ the launchd agent and the Claude classifier for uncertain comments. */
111
+
112
+ import {
113
+ readFileSync,
114
+ writeFileSync,
115
+ renameSync,
116
+ existsSync,
117
+ readdirSync,
118
+ mkdirSync,
119
+ openSync,
120
+ writeSync,
121
+ closeSync,
122
+ unlinkSync,
123
+ realpathSync,
124
+ } from "node:fs";
125
+ import { spawnSync, spawn } from "node:child_process";
126
+ import { randomBytes } from "node:crypto";
127
+ import { createServer } from "node:http";
128
+ import { homedir, tmpdir } from "node:os";
129
+ import { join, resolve, dirname, basename } from "node:path";
130
+ import { fileURLToPath } from "node:url";
131
+ import {
132
+ env,
133
+ writeEnv,
134
+ sleep,
135
+ instagramToken,
136
+ instagramAccount,
137
+ igClient,
138
+ cloudSettings,
139
+ cloudApi,
140
+ runnerStatus,
141
+ } from "./platforms.mjs";
142
+ import { resolveOmnichatConfig } from "./lib/omnichat/config.mjs";
143
+ import { askText } from "./lib/agents.mjs";
144
+ import { triggers } from "./lib/omnichat/match.mjs";
145
+ import {
146
+ deliveryPreview,
147
+ skipDeliveryPreview,
148
+ publicReplyText as corePublicReplyText,
149
+ newRule as coreNewRule,
150
+ ruleProblems as coreRuleProblems,
151
+ toList,
152
+ applyRuleJson,
153
+ offerText,
154
+ gateOn,
155
+ openingText,
156
+ buttonLabel,
157
+ classifyPrompt,
158
+ classifyInput,
159
+ parseClassification,
160
+ SAMPLE_POST,
161
+ SAMPLE_CAROUSEL,
162
+ } from "./lib/omnichat/text.mjs";
163
+ import { linkIn, keywordIn } from "./lib/omnichat/extract.mjs";
164
+ import { PauseError, pauseFor, isPaused } from "./lib/omnichat/errors.mjs";
165
+ import { runPass } from "./lib/omnichat/pass.mjs";
166
+ import {
167
+ recordItems as coreRecordItems,
168
+ totals,
169
+ retryFailed,
170
+ thread,
171
+ withoutDm,
172
+ publishedPlan,
173
+ } from "./lib/omnichat/record.mjs";
174
+ import { sendManual } from "./lib/omnichat/manual.mjs";
175
+ import { handleWebhook, WEBHOOK_FIELDS } from "./lib/omnichat/webhook.mjs";
176
+ import { isMainModule, homeEnvPlist, DATA_ROOT as ROOT } from "./lib/paths.mjs";
177
+
178
+ const CFG = JSON.parse(readFileSync(join(ROOT, "omnivideo.config.json"), "utf8"));
179
+ const C = resolveOmnichatConfig(CFG.omnichat?.instagram ?? {});
180
+
181
+ const DIR = join(ROOT, ".omnichat");
182
+ const LOCK_FILE = join(DIR, "running.lock");
183
+ const PAUSE_FILE = join(DIR, "pause.json");
184
+ const LOG_FILE = join(DIR, "omnichat.log");
185
+ const POSTS = join(DIR, "posts");
186
+ const AGENT_LABEL = "com.omnivideo.omnichat";
187
+ const PLIST = join(homedir(), "Library", "LaunchAgents", `${AGENT_LABEL}.plist`);
188
+ const SELF = fileURLToPath(import.meta.url);
189
+
190
+ const args = process.argv.slice(2);
191
+ const has = n => args.includes(`--${n}`);
192
+ const option = n => {
193
+ const i = args.indexOf(`--${n}`);
194
+ return i < 0 ? undefined : args[i + 1];
195
+ };
196
+ const options = n => args.flatMap((a, i) => (a === `--${n}` && args[i + 1] != null ? [args[i + 1]] : []));
197
+ function fail(msg) {
198
+ console.error(`erro: ${msg}`);
199
+ process.exit(1);
200
+ }
201
+ const readJson = file => {
202
+ try {
203
+ return JSON.parse(readFileSync(file, "utf8"));
204
+ } catch {
205
+ return null;
206
+ }
207
+ };
208
+ const nowIso = () => new Date().toISOString();
209
+ const dayTime = iso =>
210
+ new Date(iso).toLocaleString("pt-BR", { day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit" });
211
+
212
+ function writeJson(file, data) {
213
+ const tmp = `${file}.${process.pid}.tmp`;
214
+ writeFileSync(tmp, JSON.stringify(data, null, 2) + "\n");
215
+ renameSync(tmp, file);
216
+ }
217
+ const log = line => console.log(line);
218
+
219
+ /* --- the target -------------------------------------------------------------------------
220
+ The carousel dir (videos/<slug>/carousel) is a target like the video's: its name in the log and
221
+ in messages is <slug>/carousel, and what it does not say (the link, the keyword) comes from the content. */
222
+ const isCarousel = dir => basename(dir) === "carousel" && existsSync(join(dir, "carousel.json"));
223
+ const nameOf = dir => (isCarousel(dir) ? `${basename(dirname(dir))}/carousel` : basename(dir));
224
+ const thePost = dir => (isCarousel(dir) ? "o carrossel" : "o post");
225
+ const thisTarget = dir =>
226
+ isCarousel(dir) ? "este carrossel" : dir.startsWith(POSTS + "/") ? "este post" : "este vídeo";
227
+
228
+ /* --- the rule (files) -------------------------------------------------------------------- */
229
+ const ruleFile = dir => join(dir, "omnichat.json");
230
+ const recordFile = dir => join(dir, "omnichat-record.json");
231
+ const ruleOf = dir => readJson(ruleFile(dir))?.instagram ?? null;
232
+ const newRule = () => coreNewRule(C);
233
+ const ruleProblems = r => coreRuleProblems(r, C);
234
+ /* what the delivery sends, in one line: with link_button, the text and [label → link] */
235
+ const deliveryLine = (rule, username, post) => deliveryPreview(rule, username, post, C);
236
+ const publicReplyText = (rule, username) => corePublicReplyText(rule, username, C);
237
+ const sampleFor = dir => (isCarousel(dir) ? SAMPLE_CAROUSEL : SAMPLE_POST);
238
+ const postOf = dir => readJson(join(dir, "publication.json"))?.instagram?.url ?? null;
239
+ const mediaOf = dir => readJson(join(dir, "publication.json"))?.instagram?.id ?? null;
240
+
241
+ /* The agent reads the uncertain comments with what the post offers and says which ask for the
242
+ delivery: the core's injected classifier, on this machine. Runs on the agent accounts in rotation
243
+ (tools/lib/agents.mjs; ~/.claude is never used), Claude with the omnichat.instagram.context_model
244
+ model. Returns { id: true|false } and, if no agent answers, null: the comment stays uncertain. */
245
+ const runAsync = input => (bin, args, env) =>
246
+ new Promise(done => {
247
+ const p = spawn(bin, args, { cwd: ROOT, env });
248
+ let stdout = "",
249
+ stderr = "";
250
+ const timer = setTimeout(() => p.kill(), 120_000);
251
+ p.stdout.on("data", d => {
252
+ stdout += d;
253
+ });
254
+ p.stderr.on("data", d => {
255
+ stderr += d;
256
+ });
257
+ p.on("error", error => {
258
+ clearTimeout(timer);
259
+ done({ error, stdout, stderr });
260
+ });
261
+ p.on("close", status => {
262
+ clearTimeout(timer);
263
+ done({ status, stdout, stderr });
264
+ });
265
+ p.stdin.end(input);
266
+ });
267
+
268
+ const classifierFor = (dir, rule) => async items => {
269
+ if (!items.length) return {};
270
+ const prompt = classifyPrompt(offerText(rule, readJson(join(dir, "publication.json"))));
271
+ const result = await askText(
272
+ { root: ROOT, cfg: CFG, prompt, claudeModel: C.context_model, tmpDir: tmpdir() },
273
+ runAsync(classifyInput(items)),
274
+ ).then(
275
+ r => ({ out: r.text }),
276
+ e => ({ error: e.message }),
277
+ );
278
+ if (result.error) {
279
+ console.log(
280
+ `${nowIso()} ${nameOf(dir)}: o agente não decidiu ${items.length} comentário(s) em dúvida: ${result.error}`,
281
+ );
282
+ return null;
283
+ }
284
+ try {
285
+ return parseClassification(result.out);
286
+ } catch {
287
+ console.log(`${nowIso()} ${nameOf(dir)}: o agente não devolveu JSON: ${result.out.trim().slice(0, 120)}`);
288
+ return null;
289
+ }
290
+ };
291
+
292
+ function rule(dir) {
293
+ const file = ruleFile(dir);
294
+ const all = readJson(file) ?? {};
295
+ const before = all.instagram ?? newRule();
296
+ const r = { ...before };
297
+ const json = option("json");
298
+ if (json != null) {
299
+ let j;
300
+ try {
301
+ j = JSON.parse(json);
302
+ } catch (e) {
303
+ fail(`--json: ${e.message}`);
304
+ }
305
+ applyRuleJson(r, j);
306
+ }
307
+ if (options("keyword").length) r.keywords = toList(options("keyword"));
308
+ if (has("any")) r.any_comment = true;
309
+ if (has("keywords")) r.any_comment = false;
310
+ if (option("link") !== undefined) r.link = option("link").trim();
311
+ if (option("dm") !== undefined) r.dm = option("dm").trim();
312
+ if (options("public-reply").length) r.public_replies = toList(options("public-reply").join("\n").split("\n"));
313
+ if (has("no-public-reply")) r.public_replies = [];
314
+ if (has("no-generic")) r.generic_phrases = false;
315
+ if (has("generic")) r.generic_phrases = true;
316
+ if (option("offer") !== undefined) r.offer = option("offer").trim();
317
+ if (has("quote-post")) r.quote_post = true;
318
+ if (has("no-quote-post")) r.quote_post = false;
319
+ if (has("button")) r.button = true;
320
+ if (has("no-button")) r.button = false;
321
+ if (has("require-follow")) r.require_follow = true;
322
+ if (has("no-require-follow")) r.require_follow = false;
323
+ if (option("opening-dm") !== undefined) r.opening_dm = option("opening-dm").trim();
324
+ if (option("button-label") !== undefined) r.button_label = option("button-label").trim();
325
+ if (option("not-following-dm") !== undefined) r.not_following_dm = option("not-following-dm").trim();
326
+ if (option("retry-label") !== undefined) r.retry_label = option("retry-label").trim();
327
+ if (has("link-button")) r.link_button = true;
328
+ if (has("no-link-button")) r.link_button = false;
329
+ if (option("link-label") !== undefined) r.link_label = option("link-label").trim();
330
+ if (has("follow-skip")) r.follow_skip = true;
331
+ if (has("no-follow-skip")) r.follow_skip = false;
332
+ if (option("skip-label") !== undefined) r.skip_label = option("skip-label").trim();
333
+ if (option("skip-dm") !== undefined) r.skip_dm = option("skip-dm").trim();
334
+ if (option("answered") !== undefined) {
335
+ if (!["skip", "send"].includes(option("answered"))) fail("--answered skip|send");
336
+ r.already_replied = option("answered");
337
+ }
338
+ if (options("answered-if").length) r.already_replied_if = toList(options("answered-if").join("\n").split("\n"));
339
+
340
+ const problems = ruleProblems(r);
341
+ /* an enabled rule that stopped adding up is not written; a disabled one keeps the draft */
342
+ if (problems.length && r.enabled) fail("a regra está ligada e não ficaria de pé:\n " + problems.join("\n "));
343
+ r.updated_at = nowIso();
344
+ all.instagram = r;
345
+ writeJson(file, all);
346
+ syncRule(dir);
347
+ if (has("json")) {
348
+ console.log(JSON.stringify({ rule: r, problems }));
349
+ return;
350
+ }
351
+ describe(dir, r);
352
+ if (problems.length) console.log("falta para ligar:\n " + problems.join("\n "));
353
+ else if (!r.enabled)
354
+ console.log(`desligada: para responder, node tools/omnichat.mjs enable ${relativePath(dir)} --confirm`);
355
+ }
356
+
357
+ const relativePath = dir => (dir.startsWith(ROOT + "/") ? dir.slice(ROOT.length + 1) : dir);
358
+
359
+ function describe(dir, r) {
360
+ console.log(`${nameOf(dir)}: ${r.enabled ? "ligada" : "desligada"}`);
361
+ console.log(
362
+ ` dispara: ${r.any_comment ? "qualquer comentário" : (r.keywords ?? []).map(p => `"${p}"`).join(", ") || "(sem palavra)"}${!r.any_comment && r.generic_phrases !== false ? ` e os pedidos genéricos (${C.generic_phrases.map(g => `"${g}"`).join(", ")}), com o Claude lendo o contexto quando vêm no meio de uma frase` : ""}`,
363
+ );
364
+ const dm = deliveryLine(r, "fulano", postOf(dir) ?? sampleFor(dir)).replace(/\n/g, " ⏎ ");
365
+ if (gateOn(r)) {
366
+ console.log(` DM: ${openingText(r, "fulano", C)} [${buttonLabel(r, C)}]`);
367
+ console.log(` ao tocar${r.require_follow ? ", se seguir a conta" : ""}: ${dm}`);
368
+ if (r.require_follow)
369
+ console.log(
370
+ ` se não seguir: pede para seguir e toca de novo${r.follow_skip !== false ? `; na segunda vez, com "${r.skip_label || C.default_skip_label}"` : ""}`,
371
+ );
372
+ if (r.require_follow && r.follow_skip !== false)
373
+ console.log(
374
+ ` ao receber sem seguir: ${skipDeliveryPreview(r, "fulano", postOf(dir) ?? sampleFor(dir), C).replace(/\n/g, " ⏎ ")}`,
375
+ );
376
+ } else console.log(` DM: ${dm}`);
377
+ const pub = r.public_replies ?? C.default_public_replies;
378
+ console.log(` pública: ${pub.length ? pub.map(t => `"${t}"`).join(" | ") : "nenhuma (só DM)"}`);
379
+ const ifSays = r.already_replied_if ?? [];
380
+ console.log(
381
+ ` já respondidos por você: ${r.already_replied === "send" ? `manda${ifSays.length ? ` se a sua resposta disser ${ifSays.map(t => `"${t}"`).join(" ou ")}` : " mesmo assim"}` : "pula"}`,
382
+ );
383
+ }
384
+
385
+ function enable(dir) {
386
+ if (!has("confirm")) fail("ligar é responder para fora, em nome da conta: só com pedido do usuário (--confirm)");
387
+ const file = ruleFile(dir);
388
+ const all = readJson(file);
389
+ if (!all?.instagram)
390
+ fail(
391
+ `${thisTarget(dir)} não tem regra: node tools/omnichat.mjs rule ${relativePath(dir)} --keyword QUERO --link https://…`,
392
+ );
393
+ const problems = ruleProblems(all.instagram);
394
+ if (problems.length) fail("a regra ainda não fecha:\n " + problems.join("\n "));
395
+ all.instagram = { ...all.instagram, enabled: true, enabled_at: nowIso() };
396
+ writeJson(file, all);
397
+ syncRule(dir);
398
+ describe(dir, all.instagram);
399
+ if (!readJson(join(dir, "publication.json"))?.instagram?.id)
400
+ console.log(`${thePost(dir)} ainda não foi publicado: começa a responder quando ele subir`);
401
+ if (!agentStatus().loaded)
402
+ console.log("aviso: o agente está desligado e nada é respondido sozinho: node tools/omnichat.mjs agent install");
403
+ }
404
+
405
+ /* disabling without an enabled rule also writes: it is the "don't enable on your own" that `published` respects */
406
+ function disable(dir) {
407
+ const file = ruleFile(dir);
408
+ const all = readJson(file) ?? {};
409
+ if (!all.instagram?.enabled && all.instagram?.disabled_at) {
410
+ console.log("já estava desligada");
411
+ return;
412
+ }
413
+ const already = all.instagram && !all.instagram.enabled;
414
+ all.instagram = { ...(all.instagram ?? newRule()), enabled: false, disabled_at: nowIso() };
415
+ writeJson(file, all);
416
+ syncRule(dir);
417
+ console.log(
418
+ `${nameOf(dir)}: ${already || !all.instagram.enabled_at ? "fica desligada" : "desligada"}${C.enable_on_publish ? `, e não liga sozinha quando ${thePost(dir)} subir` : ""}`,
419
+ );
420
+ }
421
+
422
+ /* what failed without a delivered DM goes back to the queue (after swapping the token, for example);
423
+ past Meta's deadline there is no way back */
424
+ async function retry(dir) {
425
+ if (cloudOwns()) {
426
+ const media = mediaOf(dir);
427
+ if (!media) fail(`${thePost(dir)} ainda não foi publicado`);
428
+ const { n, old } = await cloudApi("POST", `/api/posts/${media}/retry`, {});
429
+ await pullRecord(dir);
430
+ console.log(n ? `${n} comentário(s) de volta na fila da cloud` : "nada para refazer");
431
+ if (old) console.log(`${old} passaram dos 7 dias e não podem mais receber a DM`);
432
+ return;
433
+ }
434
+ const file = recordFile(dir);
435
+ const all = readJson(file);
436
+ const { n, old } = retryFailed(all?.instagram, Date.now());
437
+ if (n) writeJson(file, all);
438
+ if (n && existsSync(PAUSE_FILE)) unlinkSync(PAUSE_FILE);
439
+ console.log(
440
+ n
441
+ ? `${n} comentário(s) de volta na fila${ruleOf(dir)?.enabled ? "" : " (a regra está desligada: ligue para responder)"}`
442
+ : "nada para refazer",
443
+ );
444
+ if (old) console.log(`${old} passaram dos 7 dias e não podem mais receber a DM`);
445
+ }
446
+
447
+ async function test(dir, comment) {
448
+ const r = ruleOf(dir);
449
+ if (!r) fail(`${thisTarget(dir)} não tem regra`);
450
+ if (comment == null) fail('passe o comentário: node tools/omnichat.mjs test videos/<slug> "quero o link"');
451
+ let d = triggers(r, comment, C);
452
+ if (d === "uncertain") {
453
+ const resp = await classifierFor(dir, r)([["t", { text: comment }]]);
454
+ if (!resp) fail("o comentário ficou em dúvida e o Claude não respondeu");
455
+ console.log(`pedido no meio de uma frase: o Claude ${resp.t === true ? "leu como pedido" : "não leu como pedido"}`);
456
+ d = resp.t === true ? "yes" : false;
457
+ }
458
+ if (!d) {
459
+ console.log(`não dispara: "${comment}"`);
460
+ return;
461
+ }
462
+ console.log(`dispara: "${comment}"`);
463
+ const dm = deliveryLine(r, "fulano", postOf(dir) ?? sampleFor(dir)).replace(/\n/g, " ⏎ ");
464
+ if (gateOn(r)) {
465
+ console.log(` DM: ${openingText(r, "fulano", C)} [${buttonLabel(r, C)}]`);
466
+ console.log(` ao tocar${r.require_follow ? " (seguindo a conta)" : ""}: ${dm}`);
467
+ } else console.log(` DM: ${dm}`);
468
+ const p = publicReplyText(r, "fulano");
469
+ console.log(` pública: ${p ?? "nenhuma (só DM)"}`);
470
+ }
471
+
472
+ /* --- one pass: the core's runPass, on this target's files -------------------------------- */
473
+ async function runTarget(dir, account, dryRun) {
474
+ const rule = ruleOf(dir);
475
+ const name = nameOf(dir);
476
+ const media = mediaOf(dir);
477
+ if (!media) {
478
+ if (dryRun) console.log(`${name}: ${thePost(dir)} ainda não foi publicado`);
479
+ return;
480
+ }
481
+ const recFile = recordFile(dir);
482
+ const all = readJson(recFile) ?? {};
483
+ await runPass({
484
+ rule,
485
+ media,
486
+ stored: all.instagram,
487
+ account,
488
+ name,
489
+ postFallback: postOf(dir),
490
+ dryRun,
491
+ classify: classifierFor(dir, rule),
492
+ save: r => {
493
+ all.instagram = r;
494
+ writeJson(recFile, all);
495
+ },
496
+ C,
497
+ log,
498
+ });
499
+ }
500
+
501
+ /* --- by hand: who was left without the DM, and the chosen sending -----------------------------
502
+ `comments` reads the post now, without waiting for the agent, and lists who did not get the DM:
503
+ what the rule skipped (already replied by you, Claude read it as something else), what has not
504
+ been picked up yet, what failed and what does not trigger. Read only: nothing is written.
505
+ `send` sends the rule's DM (and the public reply, unless --no-public-reply) to the chosen
506
+ comments, even ones that don't trigger: it goes outside, on behalf of the account, and needs
507
+ --confirm. Goes at the agent's pace (pause_between_s, up to max_per_pass at a time), with its
508
+ lock, and respects the account pause; the record gets `manual: true`, and whoever already got
509
+ it does not get it again. What fails transiently the agent retries, like its own.
510
+ --public-reply replaces the rule's random replies with the written text. --public-reply-only
511
+ replies only on the comment, without a DM: it has no 7-day deadline nor needs the rule, goes once
512
+ (does not go back to the queue) and a new comment in the record is skipped, like one you reply to in the app. */
513
+ async function comments(dir) {
514
+ const media = mediaOf(dir);
515
+ if (!media) fail(`${thePost(dir)} ainda não foi publicado`);
516
+ const rule = ruleOf(dir);
517
+ await pullRecord(dir);
518
+ const { token } = await instagramToken();
519
+ /* `next` is left when the read stopped at the page limit: the newest comments only */
520
+ const { read, next } = await igClient(token).comments(media, 0);
521
+ const truncated = !!next;
522
+ const us = (await instagramAccount(token)).username?.toLowerCase() ?? "";
523
+ const rec = readJson(recordFile(dir))?.instagram?.comments ?? {};
524
+ const items = withoutDm({ read, us, rec, rule, now: Date.now(), C });
525
+ const post = (await igClient(token).media(media, "permalink")).permalink ?? postOf(dir);
526
+ const chat = thread({ read, us, rec, rule, now: Date.now(), post, C });
527
+ const s = { checked_at: nowIso(), read: read.length, truncated, without_dm: items.length, items, thread: chat };
528
+ if (has("json")) {
529
+ console.log(JSON.stringify(s));
530
+ return;
531
+ }
532
+ console.log(
533
+ `${nameOf(dir)}: ${read.length} comentário(s) lido(s)${truncated ? ` (só os ${read.length} mais novos)` : ""}, ${items.length} sem DM`,
534
+ );
535
+ for (const c of items) {
536
+ const marks = [
537
+ c.triggers === "yes" ? "pede" : c.triggers === "uncertain" ? "talvez peça" : null,
538
+ c.state && `${c.state}${c.reason ? `: ${c.reason}` : ""}`,
539
+ c.account_reply && "você respondeu no comentário",
540
+ !c.within_deadline && "passou dos 7 dias",
541
+ ]
542
+ .filter(Boolean)
543
+ .join(" · ");
544
+ console.log(
545
+ `${c.id} ${dayTime(c.commented_at)} @${(c.username ?? "?").padEnd(20)} "${c.text.slice(0, 50)}"${marks ? ` ${marks}` : ""}`,
546
+ );
547
+ }
548
+ }
549
+
550
+ /* Deletes comments on Instagram (a person's on the post, or the account's own reply). It goes outside and
551
+ does not come back: --confirm. A comment the agent still had queued stops waiting (the record keeps it,
552
+ with the DM that went, if one did); with the cloud answering, its record is not touched from here. */
553
+ async function remove(dir, ids) {
554
+ if (!has("confirm")) fail("apagar não tem volta: só com pedido do usuário (--confirm)");
555
+ ids = [...new Set(ids)];
556
+ if (!ids.length || ids.some(i => !/^\d+$/.test(i)))
557
+ fail("passe os ids dos comentários: node tools/omnichat.mjs delete videos/<slug> <id> [<id>…] --confirm");
558
+ if (!mediaOf(dir)) fail(`${thePost(dir)} ainda não foi publicado`);
559
+ const { token } = await instagramToken();
560
+ const client = igClient(token);
561
+ const results = [];
562
+ for (const id of ids) {
563
+ try {
564
+ await client.deleteComment(id);
565
+ results.push({ id, state: "done", reason: null });
566
+ } catch (e) {
567
+ results.push({ id, state: "failed", reason: e.message });
568
+ }
569
+ }
570
+ const gone = new Set(results.filter(x => x.state === "done").map(x => x.id));
571
+ const recFile = recordFile(dir);
572
+ const all = readJson(recFile);
573
+ if (gone.size && all?.instagram?.comments && !cloudOwns()) {
574
+ let changed = false;
575
+ for (const id of gone) {
576
+ const c = all.instagram.comments[id];
577
+ if (!c) continue;
578
+ c.deleted_at = nowIso();
579
+ if (c.state === "queued" || c.state === "retry" || c.state === "uncertain")
580
+ Object.assign(c, { state: "skipped", reason: "apagado por você" });
581
+ changed = true;
582
+ }
583
+ if (changed) writeJson(recFile, all);
584
+ }
585
+ if (has("json")) {
586
+ console.log(JSON.stringify({ deleted: gone.size, results }));
587
+ return;
588
+ }
589
+ for (const x of results) console.log(`${x.id}: ${x.state === "done" ? "apagado" : `falhou: ${x.reason}`}`);
590
+ console.log(`${gone.size} de ${ids.length} apagados`);
591
+ }
592
+
593
+ async function send(dir, ids) {
594
+ if (!has("confirm")) fail("enviar é responder para fora, em nome da conta: só com pedido do usuário (--confirm)");
595
+ ids = [...new Set(ids)];
596
+ if (!ids.length || ids.some(i => !/^\d+$/.test(i)))
597
+ fail("passe os ids dos comentários: node tools/omnichat.mjs send videos/<slug> <id> [<id>…] --confirm");
598
+ const publicOnly = has("public-reply-only");
599
+ const written = option("public-reply")?.trim();
600
+ if (has("public-reply") && !written) fail("--public-reply sem texto");
601
+ if (written?.length > 2200) fail("resposta no comentário com mais de 2.200 caracteres");
602
+ if (publicOnly && has("no-public-reply")) fail("--public-reply-only e --no-public-reply não vão juntos");
603
+ /* the rule's public replies say "te mandei no direct": without a DM, only the written text */
604
+ if (publicOnly && !written) fail('sem a DM, escreva a resposta: --public-reply-only --public-reply "texto"');
605
+ const rule = ruleOf(dir);
606
+ if (!publicOnly) {
607
+ if (!rule)
608
+ fail(
609
+ `este vídeo não tem regra, e a DM sai do texto dela: node tools/omnichat.mjs rule ${relativePath(dir)} --keyword QUERO --link https://…`,
610
+ );
611
+ const problems = ruleProblems(rule);
612
+ if (problems.length) fail("a regra ainda não fecha:\n " + problems.join("\n "));
613
+ }
614
+ const media = mediaOf(dir);
615
+ if (!media) fail(`${thePost(dir)} ainda não foi publicado`);
616
+ /* the cloud answers this post: the send goes through it, under its lease and its pause */
617
+ if (cloudOwns()) {
618
+ const out = await cloudApi("POST", `/api/posts/${media}/send`, {
619
+ ids,
620
+ public_reply: !has("no-public-reply"),
621
+ public_reply_only: publicOnly,
622
+ text: written,
623
+ });
624
+ await pullRecord(dir);
625
+ return printSent(out.results ?? [], ids, publicOnly);
626
+ }
627
+ const pause = readJson(PAUSE_FILE);
628
+ if (isPaused(pause, Date.now())) fail(`em pausa até ${dayTime(pause.until)}: ${pause.reason}`);
629
+ /* the agent's lock: its pass takes seconds */
630
+ let release = null;
631
+ // biome-ignore lint/suspicious/noAssignInExpressions: retry until the lock is taken
632
+ for (let i = 0; i < 30 && !(release = lock()); i++) await sleep(1000);
633
+ if (!release) fail("uma passada do agente está demorando: tente de novo em um minuto");
634
+ const used = has("no-public-reply")
635
+ ? { ...rule, public_replies: [] }
636
+ : written
637
+ ? { ...rule, public_replies: [written] }
638
+ : rule;
639
+ let results = [];
640
+ try {
641
+ const { token, id } = await instagramToken();
642
+ const recFile = recordFile(dir);
643
+ const all = readJson(recFile) ?? {};
644
+ results = await sendManual({
645
+ ids,
646
+ rule: used,
647
+ publicOnly,
648
+ written,
649
+ media,
650
+ stored: all.instagram,
651
+ account: { client: igClient(token), id },
652
+ name: nameOf(dir),
653
+ postFallback: postOf(dir),
654
+ save: r => {
655
+ all.instagram = r;
656
+ writeJson(recFile, all);
657
+ },
658
+ onPause: p => writeJson(PAUSE_FILE, p),
659
+ C,
660
+ log,
661
+ });
662
+ } finally {
663
+ release();
664
+ }
665
+ printSent(results, ids, publicOnly);
666
+ }
667
+
668
+ function printSent(results, ids, publicOnly) {
669
+ const sent = results.filter(x => x.state === "done").length;
670
+ if (has("json")) {
671
+ console.log(JSON.stringify({ sent, results }));
672
+ return;
673
+ }
674
+ for (const x of results)
675
+ console.log(
676
+ `@${x.username ?? x.id}: ${x.state === "done" ? (publicOnly ? "resposta no comentário" : `DM${x.public_reply ? " e pública" : ""}`) : `${x.state}${x.reason ? `: ${x.reason}` : ""}`}`,
677
+ );
678
+ console.log(`${sent} de ${ids.length} ${publicOnly ? "respondidos no comentário" : "com a DM entregue"}`);
679
+ }
680
+
681
+ /* --- run: every target with an enabled rule ------------------------------------------------ */
682
+ function videosWithRule() {
683
+ const dirs = [];
684
+ for (const base of [join(ROOT, "videos"), POSTS]) {
685
+ try {
686
+ dirs.push(...readdirSync(base).map(n => join(base, n)));
687
+ } catch {
688
+ /* does not exist yet */
689
+ }
690
+ }
691
+ /* each content's carousel is another post, with its own rule */
692
+ try {
693
+ dirs.push(...readdirSync(join(ROOT, "videos")).map(n => join(ROOT, "videos", n, "carousel")));
694
+ } catch {
695
+ /* no videos/ */
696
+ }
697
+ return dirs.filter(p => existsSync(ruleFile(p)));
698
+ }
699
+
700
+ /* A post that is already live and did not come from omnivideo (or did, and you only have the link):
701
+ finds the media by the link among the account's posts and creates .omnichat/posts/<code>/ with
702
+ its publication.json. Works for Reel, carousel and photo (`type` is the media_type). A post that
703
+ came from omnivideo (a video's Reel, or its carousel) uses its own dir. */
704
+ async function post(target) {
705
+ if (!target)
706
+ fail(
707
+ "passe o link do post (Reel, carrossel ou foto): node tools/omnichat.mjs post https://www.instagram.com/p/…/ --keyword QUERO --link https://…",
708
+ );
709
+ const code = /instagram\.com\/(?:[\w.]+\/)?(?:reels?|p|tv)\/([\w-]+)/.exec(target)?.[1];
710
+ const { token } = await instagramToken();
711
+ const client = igClient(token);
712
+ let media = null;
713
+ let url = client.mediaListUrl("id,permalink,timestamp,media_type,media_product_type", "50");
714
+ for (let i = 0; url && !media && i < 20; i++) {
715
+ const j = await client.get(url, "Instagram (posts)");
716
+ media = (j.data ?? []).find(m => (code ? m.permalink?.includes(`/${code}/`) : m.id === target)) ?? null;
717
+ url = j.paging?.next ?? null;
718
+ }
719
+ if (!media) fail(`não achei ${target} entre os posts de @${(await instagramAccount(token)).username}`);
720
+ const videoDir = (() => {
721
+ try {
722
+ return readdirSync(join(ROOT, "videos"))
723
+ .flatMap(n => [join(ROOT, "videos", n), join(ROOT, "videos", n, "carousel")])
724
+ .find(p => readJson(join(p, "publication.json"))?.instagram?.id === media.id);
725
+ } catch {
726
+ return null;
727
+ }
728
+ })();
729
+ const dir = videoDir ?? join(POSTS, code ?? media.id);
730
+ if (!videoDir) {
731
+ mkdirSync(dir, { recursive: true });
732
+ writeJson(join(dir, "publication.json"), {
733
+ instagram: {
734
+ id: media.id,
735
+ url: media.permalink,
736
+ published_at: new Date(Date.parse(media.timestamp)).toISOString(),
737
+ type: media.media_type ?? null,
738
+ },
739
+ });
740
+ }
741
+ const type =
742
+ media.media_product_type === "REELS"
743
+ ? "Reel"
744
+ : media.media_type === "CAROUSEL_ALBUM"
745
+ ? "carrossel"
746
+ : media.media_type === "IMAGE"
747
+ ? "foto"
748
+ : "post";
749
+ console.log(`${type} ${media.permalink} → ${relativePath(dir)}`);
750
+ rule(dir);
751
+ }
752
+
753
+ /* --- what the video already says: the link and the keyword -----------------------------------
754
+ The studio imports both (the Publish stage shows what it found) and `published` uses them.
755
+ The scanning is the core's (lib/omnichat/extract.mjs); this side gathers the texts. */
756
+ const readText = file => {
757
+ try {
758
+ return readFileSync(file, "utf8");
759
+ } catch {
760
+ return null;
761
+ }
762
+ };
763
+
764
+ /* The link the DM delivers: the first http(s) URL in the description and caption (publication.json),
765
+ in brief.md and in source/request.md, in that order. Social network links don't count: they are
766
+ the post the topic came from, or the published video itself. `found_in` says where it was found ("no brief.md"). */
767
+ export function videoLink(dir) {
768
+ const pubText = file => {
769
+ const pub = readJson(file);
770
+ return [pub?.description, pub?.caption, pub?.caption_instagram].filter(s => typeof s === "string").join("\n");
771
+ };
772
+ /* the carousel: its caption and script first; then what the content (the parent dir) says */
773
+ const sources = isCarousel(dir)
774
+ ? [
775
+ [
776
+ "na legenda do carrossel",
777
+ [pubText(join(dir, "publication.json")), readJson(join(dir, "carousel.json"))?.caption ?? ""].join("\n"),
778
+ ],
779
+ ["no roteiro do carrossel", readText(join(dir, "script.md")) ?? ""],
780
+ ["na descrição da publicação do vídeo", pubText(join(dirname(dir), "publication.json"))],
781
+ ["no brief.md", readText(join(dirname(dir), "brief.md")) ?? ""],
782
+ ["no source/request.md", readText(join(dirname(dir), "source", "request.md")) ?? ""],
783
+ ]
784
+ : [
785
+ ["na descrição da publicação", pubText(join(dir, "publication.json"))],
786
+ ["no brief.md", readText(join(dir, "brief.md")) ?? ""],
787
+ ["no source/request.md", readText(join(dir, "source", "request.md")) ?? ""],
788
+ ];
789
+ const found = linkIn(sources);
790
+ if (found) return found;
791
+ /* the carousel of a video that already delivers a link through omnichat delivers the same */
792
+ const fromVideo = isCarousel(dir) ? String(ruleOf(dirname(dir))?.link ?? "").trim() : "";
793
+ return fromVideo ? { link: fromVideo, found_in: "na regra do omnichat do vídeo" } : null;
794
+ }
795
+
796
+ /* The keyword the narration asks for: "Comenta REA que eu te mando o link", "comente muse que te envio".
797
+ The scenes.json lines first, which is what the voice says; then the script.
798
+ In the carousel: the slides' text and carousel.json's caption, then its script. */
799
+ export function narrationKeyword(dir) {
800
+ const scenes = readJson(join(dir, "scenes.json"))?.scenes ?? [];
801
+ const car = isCarousel(dir) ? readJson(join(dir, "carousel.json")) : null;
802
+ return keywordIn([
803
+ ...scenes.flatMap(s => (s.lines ?? []).map(l => l.text ?? "")),
804
+ ...(car ? [...(car.slides ?? []).map(s => [s.title, s.text].filter(Boolean).join(" ")), car.caption ?? ""] : []),
805
+ readText(join(dir, "script.md")) ?? "",
806
+ ]);
807
+ }
808
+
809
+ /* --- on publish -------------------------------------------------------------------------
810
+ publish.mjs calls `published` right after the Reel (or the carousel) goes up. An enabled rule already applies to it
811
+ (the id comes from publication.json). With omnichat.instagram.enable_on_publish, the permanent approval
812
+ to reply without the enable click (like publish.auto.approved), one not yet enabled enables now
813
+ (the decision is the core's publishedPlan). */
814
+ const realPath = p => {
815
+ try {
816
+ return realpathSync(p);
817
+ } catch {
818
+ return p;
819
+ }
820
+ };
821
+ const triggerLabel = r =>
822
+ r.any_comment ? "qualquer comentário" : (r.keywords ?? []).map(p => `"${p}"`).join(", ") || "sem palavra";
823
+
824
+ function published(dir) {
825
+ /* the agent only checks videos in this project's videos/ (and each one's carousel) */
826
+ const content = isCarousel(dir) ? dirname(dir) : dir;
827
+ if (realPath(dirname(content)) !== realPath(join(ROOT, "videos"))) return;
828
+ const file = ruleFile(dir);
829
+ const all = readJson(file) ?? {};
830
+ const agentHint = () => {
831
+ if (!cloudOwns() && !agentStatus().loaded)
832
+ console.log(
833
+ "aviso: omnichat: o agente está desligado e nada é respondido sozinho: node tools/omnichat.mjs agent install",
834
+ );
835
+ };
836
+ const plan = publishedPlan({
837
+ rule: all.instagram ?? null,
838
+ enableOnPublish: !!C.enable_on_publish,
839
+ keyword: () => narrationKeyword(dir),
840
+ link: () => videoLink(dir),
841
+ now: Date.now(),
842
+ C,
843
+ });
844
+ /* --plan: the rule that would be live on the Reel, without writing (the cloud schedule takes it along
845
+ and turns it on the moment the Reel goes up) */
846
+ if (has("plan")) {
847
+ console.log(JSON.stringify({ kind: plan.kind, rule: "rule" in plan && plan.kind !== "draft" ? plan.rule : null }));
848
+ return;
849
+ }
850
+ if (plan.kind === "already_enabled") {
851
+ /* enabled before the Reel had an id here (the cloud schedule's result arrives later): the cloud only
852
+ gets the rule now that the id is in publication.json */
853
+ syncRule(dir);
854
+ console.log(
855
+ `omnichat: ligado (${triggerLabel(plan.rule)}): responde n${thePost(dir).slice(1)} a partir da próxima passada`,
856
+ );
857
+ agentHint();
858
+ return;
859
+ }
860
+ if (plan.kind === "off") return;
861
+ if (plan.kind === "disabled_by_user") {
862
+ console.log(`omnichat: desligado por você n${isCarousel(dir) ? "este carrossel" : "este vídeo"}; fica desligado`);
863
+ return;
864
+ }
865
+ if (plan.kind === "any_comment") {
866
+ console.log(
867
+ `omnichat: a regra dispara com qualquer comentário e não liga sozinha: node tools/omnichat.mjs enable ${relativePath(dir)} --confirm`,
868
+ );
869
+ return;
870
+ }
871
+ if (plan.kind === "no_keyword") {
872
+ console.log(
873
+ `omnichat: ${isCarousel(dir) ? "o roteiro do carrossel" : "a narração"} não pede comentário ("comenta X que eu te mando…"): nada para ligar`,
874
+ );
875
+ return;
876
+ }
877
+ if (plan.born)
878
+ console.log(
879
+ `omnichat: termo "${plan.born.keyword}", do pedido ${isCarousel(dir) ? "do carrossel" : "da narração"}${plan.born.found ? `; link achado ${plan.born.found.found_in}` : ""}`,
880
+ );
881
+ all.instagram = plan.rule;
882
+ writeJson(file, all);
883
+ syncRule(dir);
884
+ if (plan.kind === "draft") {
885
+ console.log(
886
+ `omnichat: a regra ficou gravada, desligada; falta para ligar (etapa Publicar ou página Omnichat):\n ${plan.problems.join("\n ")}`,
887
+ );
888
+ return;
889
+ }
890
+ console.log("omnichat: ligado ao publicar (omnichat.instagram.enable_on_publish)");
891
+ describe(dir, all.instagram);
892
+ agentHint();
893
+ }
894
+
895
+ const alive = pid => {
896
+ if (!pid) return false;
897
+ try {
898
+ process.kill(pid, 0);
899
+ return true;
900
+ } catch (e) {
901
+ return e.code === "EPERM";
902
+ }
903
+ };
904
+
905
+ /* one pass at a time: the agent every minute and someone in the terminal don't reply to the same comment twice */
906
+ function lock() {
907
+ mkdirSync(DIR, { recursive: true });
908
+ for (let i = 0; i < 2; i++) {
909
+ try {
910
+ const fd = openSync(LOCK_FILE, "wx");
911
+ writeSync(fd, String(process.pid));
912
+ closeSync(fd);
913
+ return () => {
914
+ try {
915
+ unlinkSync(LOCK_FILE);
916
+ } catch {
917
+ /* already gone */
918
+ }
919
+ };
920
+ } catch (e) {
921
+ if (e.code !== "EEXIST") throw e;
922
+ if (alive(+readFileSync(LOCK_FILE, "utf8"))) return null;
923
+ try {
924
+ unlinkSync(LOCK_FILE);
925
+ } catch {
926
+ /* someone else took it */
927
+ }
928
+ }
929
+ }
930
+ return null;
931
+ }
932
+
933
+ async function run(only) {
934
+ const dryRun = has("dry-run");
935
+ /* the cloud answers: this machine would answer the same comments twice */
936
+ if (cloudOwns()) {
937
+ if (only && dryRun) {
938
+ const media = mediaOf(only);
939
+ if (!media) fail(`${thePost(only)} ainda não foi publicado`);
940
+ const out = await cloudApi("POST", `/api/posts/${media}/run`, { dry_run: true });
941
+ for (const l of out.log ?? []) console.log(l);
942
+ if (!out.ran && out.reason) console.log(out.reason);
943
+ } else if (only) console.log("a cloud responde este post (cloud.omnichat): nada roda aqui");
944
+ return;
945
+ }
946
+ const dirs = (only ? [only] : videosWithRule()).filter(p => (dryRun ? !!ruleOf(p) : ruleOf(p)?.enabled));
947
+ if (only && !dirs.length)
948
+ fail(
949
+ dryRun
950
+ ? `${thisTarget(only)} não tem regra`
951
+ : `a regra d${thisTarget(only)} está desligada (--dry-run para ver o que faria)`,
952
+ );
953
+ if (!dirs.length) return;
954
+ const pause = readJson(PAUSE_FILE);
955
+ if (!dryRun && isPaused(pause, Date.now())) {
956
+ if (only) console.log(`em pausa até ${dayTime(pause.until)}: ${pause.reason}`);
957
+ return;
958
+ }
959
+ const release = dryRun ? () => {} : lock();
960
+ if (!release) {
961
+ if (only) console.log("outra passada está rodando");
962
+ return;
963
+ }
964
+ try {
965
+ const { token, id } = await instagramToken();
966
+ /* the account name (to skip its own comments) is only fetched if some post is read */
967
+ let username;
968
+ const account = {
969
+ client: igClient(token),
970
+ id,
971
+ username: async () => (username ??= (await instagramAccount(token)).username?.toLowerCase() ?? ""),
972
+ };
973
+ for (const dir of dirs) {
974
+ try {
975
+ await runTarget(dir, account, dryRun);
976
+ } catch (e) {
977
+ if (e instanceof PauseError) {
978
+ const p = pauseFor(e.message, Date.now(), C);
979
+ writeJson(PAUSE_FILE, p);
980
+ console.log(`${nowIso()} pausa até ${dayTime(p.until)}: ${e.message}`);
981
+ return;
982
+ }
983
+ console.log(`${nowIso()} ${nameOf(dir)}: ${e.message}`);
984
+ }
985
+ }
986
+ } finally {
987
+ release();
988
+ }
989
+ }
990
+
991
+ /* --- log and status ---------------------------------------------------------------------- */
992
+ const recordItems = dir => coreRecordItems(readJson(recordFile(dir))?.instagram);
993
+
994
+ async function showLog(dir) {
995
+ await pullRecord(dir);
996
+ const items = recordItems(dir);
997
+ const rec = readJson(recordFile(dir))?.instagram ?? null;
998
+ if (has("json")) {
999
+ console.log(
1000
+ JSON.stringify({
1001
+ totals: totals(items),
1002
+ checked_at: rec?.checked_at ?? null,
1003
+ warning: rec?.warning ?? null,
1004
+ items,
1005
+ }),
1006
+ );
1007
+ return;
1008
+ }
1009
+ const t = totals(items);
1010
+ console.log(
1011
+ `${nameOf(dir)}: ${t.triggers} disparo(s), ${t.dms} DM, ${t.public_replies} pública(s), ${t.queued} na fila, ${t.uncertain ? `${t.uncertain} em dúvida, ` : ""}${t.failures} falha(s)${rec?.checked_at ? ` · conferido ${dayTime(rec.checked_at)}` : ""}`,
1012
+ );
1013
+ if (rec?.warning) console.log(`aviso: ${rec.warning}`);
1014
+ for (const c of items) {
1015
+ const state =
1016
+ c.state === "done"
1017
+ ? `DM ✓${c.public_reply?.at ? " · pública ✓" : ""}`
1018
+ : c.state === "failed"
1019
+ ? `falhou: ${c.error ?? ""}`
1020
+ : c.state === "retry"
1021
+ ? `tentando de novo: ${c.error ?? ""}`
1022
+ : c.state === "skipped"
1023
+ ? `pulado: ${c.reason}`
1024
+ : c.state === "uncertain"
1025
+ ? "em dúvida: o Claude ainda não leu"
1026
+ : "na fila";
1027
+ console.log(`${dayTime(c.commented_at)} @${(c.username ?? "?").padEnd(20)} "${c.text.slice(0, 40)}" ${state}`);
1028
+ }
1029
+ }
1030
+
1031
+ async function status() {
1032
+ const s = { agent: agentStatus(), pause: null, account: null, comments: null, rules: [] };
1033
+ const pause = readJson(PAUSE_FILE);
1034
+ if (isPaused(pause, Date.now())) s.pause = pause;
1035
+ if (cloudOwns()) {
1036
+ await Promise.all(videosWithRule().map(d => pullRecord(d).catch(() => {})));
1037
+ /* the cloud answers: its pause and its token are the ones that count */
1038
+ try {
1039
+ const c = await cloudApi("GET", "/api/status");
1040
+ s.cloud = { ready: true, active: !!c.active, expires: c.account?.expires ?? null };
1041
+ if (!c.active) s.cloud.reason = "a cloud não está respondendo (cloud push)";
1042
+ if (c.account?.paused_until && Date.parse(c.account.paused_until) > Date.now())
1043
+ s.pause = { until: c.account.paused_until, reason: c.account.pause_reason ?? "", at: null };
1044
+ else s.pause = null;
1045
+ } catch (e) {
1046
+ s.cloud = { ready: false, reason: `não deu para falar com a cloud: ${e.message}` };
1047
+ }
1048
+ }
1049
+ /* the hosted studio: whether this Mac is paired and connected to it */
1050
+ const runner = await runnerStatus();
1051
+ if (runner.configured)
1052
+ s.studio = { url: runner.url, paired: runner.paired, name: runner.name, online: runner.online };
1053
+ for (const dir of videosWithRule()) {
1054
+ const r = ruleOf(dir);
1055
+ if (!r) continue;
1056
+ const rec = readJson(recordFile(dir))?.instagram;
1057
+ s.rules.push({
1058
+ slug: nameOf(dir),
1059
+ dir,
1060
+ carousel: isCarousel(dir),
1061
+ enabled: !!r.enabled,
1062
+ published: !!readJson(join(dir, "publication.json"))?.instagram?.id,
1063
+ keywords: r.any_comment ? null : r.keywords,
1064
+ totals: totals(recordItems(dir)),
1065
+ checked_at: rec?.checked_at ?? null,
1066
+ warning: rec?.warning ?? null,
1067
+ });
1068
+ }
1069
+ /* the comments permission: on a post with comments, the list comes back empty without it */
1070
+ try {
1071
+ const { token } = await instagramToken();
1072
+ const c = await instagramAccount(token);
1073
+ s.account = { ready: true, username: `@${c.username}`, expires: env("INSTAGRAM_TOKEN_EXPIRES") || null };
1074
+ const client = igClient(token);
1075
+ const j = await client.recentMedia("id,comments_count", "25");
1076
+ const withComment = (j.data ?? []).find(m => m.comments_count > 0);
1077
+ if (!withComment) s.comments = { ready: null, reason: "nenhum post recente com comentário para conferir" };
1078
+ else {
1079
+ const cs = await client.commentIds(withComment.id);
1080
+ s.comments = cs.data?.length
1081
+ ? { ready: true }
1082
+ : {
1083
+ ready: false,
1084
+ reason:
1085
+ "a API devolve a lista de comentários vazia: o app precisa estar publicado (Live, em developers.facebook.com → Publicar) e o token com instagram_business_manage_comments e instagram_business_manage_messages",
1086
+ };
1087
+ }
1088
+ } catch (e) {
1089
+ if (!s.account) s.account = { ready: false, reason: e.message };
1090
+ else s.comments = { ready: false, reason: e.message };
1091
+ }
1092
+ if (has("json")) {
1093
+ console.log(JSON.stringify(s));
1094
+ return;
1095
+ }
1096
+ console.log(`${s.account.ready ? "✓" : "·"} instagram: ${s.account.ready ? s.account.username : s.account.reason}`);
1097
+ if (s.comments)
1098
+ console.log(
1099
+ `${s.comments.ready ? "✓" : "·"} comentários: ${s.comments.ready ? "lidos pela API" : s.comments.reason}`,
1100
+ );
1101
+ console.log(" DM: a permissão de mensagens só se confere no primeiro envio");
1102
+ console.log(
1103
+ ` agente: ${s.agent.loaded ? "ligado, confere a cada minuto" : "desligado (node tools/omnichat.mjs agent install)"}`,
1104
+ );
1105
+ if (s.studio)
1106
+ console.log(
1107
+ ` studio hospedado: ${s.studio.paired ? `este Mac (${s.studio.name}) ${s.studio.online ? "atende por ele agora" : "não está conectado: o studio hospedado mostra a última cópia (node tools/runner.mjs status)"}` : "este Mac não está pareado (node tools/runner.mjs login)"}`,
1108
+ );
1109
+ if (s.pause) console.log(` em pausa até ${dayTime(s.pause.until)}: ${s.pause.reason}`);
1110
+ if (!s.rules.length) console.log(" nenhum vídeo com regra");
1111
+ for (const r of s.rules) {
1112
+ console.log(
1113
+ ` ${r.enabled ? "ligada " : "desligada"} ${r.slug}${r.published ? "" : " (post ainda não publicado)"} · ${r.totals.dms} DM${r.warning ? ` · ${r.warning}` : ""}`,
1114
+ );
1115
+ }
1116
+ }
1117
+
1118
+ /* --- the cloud: the Worker in cloud/omnichat answers instead of this machine -------------------
1119
+ cloud.url in the config and OMNICHAT_CLOUD_SECRET in the .env (the Worker's SYNC_SECRET).
1120
+ `cloud push` sends what the Worker needs: the config (the default texts and limits), the Instagram
1121
+ account (token sealed there) and each published post's rule; with --records, the local record too,
1122
+ once, so whoever already got the DM never gets it again. While cloud.omnichat is false nothing
1123
+ else changes. With owns: true the cloud is the one answering: every saved rule goes up on its own,
1124
+ the record is read from there (and mirrored into omnichat-record.json for the studio), the manual
1125
+ send goes through it, and the local agent and webhook stop answering. */
1126
+ const cloudReady = () => cloudSettings().ready;
1127
+ const cloudOwns = () => cloudSettings().owns.omnichat;
1128
+ const CLOUD = {
1129
+ get url() {
1130
+ return cloudSettings().url;
1131
+ },
1132
+ };
1133
+
1134
+ /* The rule goes to the cloud. A video the cloud schedule publishes: into its job, which turns it on the
1135
+ moment the Reel is up (or at once if it already is, even before this machine reads the Reel's id).
1136
+ Otherwise onto the post, by the id in publication.json. False: nothing in the cloud to put it on yet. */
1137
+ async function pushRule(dir) {
1138
+ const rule = ruleOf(dir);
1139
+ if (!rule) return false;
1140
+ if (!isCarousel(dir) && readJson(join(dir, "publication.json"))?.schedule?.cloud) {
1141
+ const r = await cloudApi("PUT", `/api/schedule/${basename(dir)}/rule`, { rule });
1142
+ if (r.found) return true;
1143
+ }
1144
+ const media = mediaOf(dir);
1145
+ if (!media) return false;
1146
+ const pub = readJson(join(dir, "publication.json"))?.instagram ?? {};
1147
+ await cloudApi("PUT", `/api/posts/${media}`, {
1148
+ name: nameOf(dir),
1149
+ post_url: pub.url ?? null,
1150
+ published_at: pub.published_at ?? null,
1151
+ rule,
1152
+ });
1153
+ return true;
1154
+ }
1155
+
1156
+ /* a saved rule goes up when the cloud answers (the CLI waits for it before exiting: flushCloud) */
1157
+ const pendingCloud = [];
1158
+ function syncRule(dir) {
1159
+ if (!cloudOwns()) return;
1160
+ pendingCloud.push(
1161
+ pushRule(dir)
1162
+ .then(sent => {
1163
+ if (!sent) console.log(`omnichat: a regra de ${nameOf(dir)} vai para a cloud quando ${thePost(dir)} subir`);
1164
+ })
1165
+ .catch(e => console.error(`aviso: a cloud não recebeu a regra de ${nameOf(dir)}: ${e.message}`)),
1166
+ );
1167
+ }
1168
+ const flushCloud = () => Promise.all(pendingCloud.splice(0));
1169
+
1170
+ /* the cloud's record mirrored into omnichat-record.json: the studio and the log read the file */
1171
+ async function pullRecord(dir) {
1172
+ if (!cloudOwns()) return;
1173
+ const media = mediaOf(dir);
1174
+ if (!media) return;
1175
+ try {
1176
+ const out = await cloudApi("GET", `/api/posts/${media}`);
1177
+ const file = recordFile(dir);
1178
+ const all = readJson(file) ?? {};
1179
+ /* only a change is written: the write is a studio notice, and the notice makes the page read (and
1180
+ pull) again, so an unchanged record written anyway loops against the cloud */
1181
+ if (JSON.stringify(all.instagram ?? null) === JSON.stringify(out.record ?? null)) return;
1182
+ all.instagram = out.record;
1183
+ delete all.cloud;
1184
+ writeJson(file, all);
1185
+ } catch (e) {
1186
+ if (e.status !== 404) console.error(`aviso: não deu para ler o registro na cloud: ${e.message}`);
1187
+ }
1188
+ }
1189
+
1190
+ /* the cloud's health for the studio: answering, its pause and when the token expires */
1191
+ async function cloudHealth() {
1192
+ try {
1193
+ const c = await cloudApi("GET", "/api/status");
1194
+ const paused = c.account?.paused_until && Date.parse(c.account.paused_until) > Date.now();
1195
+ return {
1196
+ owns: true,
1197
+ ready: true,
1198
+ active: !!c.active,
1199
+ expires: c.account?.expires ?? null,
1200
+ pause: paused ? { until: c.account.paused_until, reason: c.account.pause_reason ?? "" } : null,
1201
+ };
1202
+ } catch (e) {
1203
+ return { owns: true, ready: false, reason: e.message };
1204
+ }
1205
+ }
1206
+
1207
+ async function cloudCmd(sub) {
1208
+ if (sub === "status") {
1209
+ console.log(
1210
+ `${CLOUD.url ? "✓" : "·"} cloud.url${CLOUD.url ? `: ${CLOUD.url}` : " (falta no omnivideo.config.json)"}`,
1211
+ );
1212
+ console.log(`${env("OMNICHAT_CLOUD_SECRET") ? "✓" : "·"} OMNICHAT_CLOUD_SECRET no .env`);
1213
+ console.log(` quem responde: ${cloudOwns() ? "a cloud (cloud.omnichat)" : "esta máquina"}`);
1214
+ if (!cloudReady()) return;
1215
+ const s = await cloudApi("GET", "/api/status");
1216
+ console.log(
1217
+ `${s.active === cloudOwns() ? "✓" : "·"} a cloud ${s.active ? "responde" : "não responde"}${s.active !== cloudOwns() ? " (diferente do config: cloud push)" : ""}`,
1218
+ );
1219
+ console.log(
1220
+ `${s.account ? "✓" : "·"} conta na cloud: ${s.account ? `@${s.account.username ?? s.account.id}${s.account.paused_until && Date.parse(s.account.paused_until) > Date.now() ? ` (em pausa: ${s.account.pause_reason})` : ""}` : "nenhuma (cloud push)"}`,
1221
+ );
1222
+ const there = new Map((s.posts ?? []).map(p => [p.media_id, p]));
1223
+ for (const dir of videosWithRule()) {
1224
+ const media = mediaOf(dir);
1225
+ if (!media) continue;
1226
+ const p = there.get(media);
1227
+ const local = !!ruleOf(dir)?.enabled;
1228
+ console.log(
1229
+ ` ${p ? (p.enabled ? "ligada " : "desligada") : "fora "} ${nameOf(dir)}${p && p.enabled !== local ? ` (aqui está ${local ? "ligada" : "desligada"}: cloud push)` : ""}`,
1230
+ );
1231
+ }
1232
+ return;
1233
+ }
1234
+ if (sub === "push") {
1235
+ const only = args[2] && !args[2].startsWith("--") ? resolve(args[2]) : null;
1236
+ await cloudApi("PUT", "/api/config", C);
1237
+ /* the cloud answers only with cloud.omnichat: never both */
1238
+ await cloudApi("PUT", "/api/active", { active: cloudOwns() });
1239
+ const { token, id } = await instagramToken();
1240
+ const acc = await instagramAccount(token);
1241
+ await cloudApi("PUT", "/api/account", {
1242
+ id,
1243
+ username: acc.username ?? null,
1244
+ token,
1245
+ expires: env("INSTAGRAM_TOKEN_EXPIRES") || null,
1246
+ });
1247
+ console.log(`config e conta @${acc.username} na cloud`);
1248
+ for (const dir of only ? [only] : videosWithRule()) {
1249
+ if (!(await pushRule(dir))) {
1250
+ if (only) console.log(`${nameOf(dir)}: sem regra ou ainda não publicado`);
1251
+ continue;
1252
+ }
1253
+ let rec = "";
1254
+ if (has("records")) {
1255
+ const record = readJson(recordFile(dir))?.instagram;
1256
+ if (record) {
1257
+ try {
1258
+ await cloudApi("PUT", `/api/posts/${mediaOf(dir)}/record`, { record, force: has("force") });
1259
+ rec = ", com o registro";
1260
+ } catch (e) {
1261
+ rec =
1262
+ e.status === 409 ? ", registro já estava lá (--force sobrescreve)" : `, registro não foi: ${e.message}`;
1263
+ }
1264
+ }
1265
+ }
1266
+ console.log(` ${nameOf(dir)}: regra ${ruleOf(dir)?.enabled ? "ligada" : "desligada"}${rec}`);
1267
+ }
1268
+ console.log(
1269
+ cloudOwns()
1270
+ ? "a cloud responde a partir de agora (cloud.omnichat)"
1271
+ : "a cloud recebeu, mas quem responde ainda é esta máquina (cloud.omnichat: true para virar)",
1272
+ );
1273
+ return;
1274
+ }
1275
+ if (sub === "pull" && args[2] && existsSync(join(resolve(args[2]), "publication.json"))) {
1276
+ if (!cloudOwns()) fail("só com cloud.omnichat: enquanto esta máquina responde, o registro daqui é o que vale");
1277
+ await pullRecord(resolve(args[2]));
1278
+ console.log(`registro de ${nameOf(resolve(args[2]))} lido da cloud`);
1279
+ return;
1280
+ }
1281
+ fail("cloud status | push [videos/<slug>] [--records] [--force] | pull videos/<slug>");
1282
+ }
1283
+
1284
+ /* --- the messaging webhook: the button's tap and the follow gate ------------------------------
1285
+ The comment's DM opens with a button (rule.button / rule.require_follow). The tap reaches here as a
1286
+ `messaging_postbacks` event; handleTap (tools/lib/omnichat/gate.mjs) checks the criterion and sends the
1287
+ link, or asks to follow. Meta needs a public HTTPS callback URL: `serve` listens on this machine and a
1288
+ tunnel (cloudflared) exposes it while the Mac is awake. A webhook handled elsewhere (a cloud function)
1289
+ uses the same handleWebhook. The record is written under the pass lock, so the agent and the tap
1290
+ don't overwrite each other. */
1291
+ const WEBHOOK_PORT = 5434;
1292
+ const gatedRules = () => videosWithRule().filter(d => gateOn(ruleOf(d) ?? {}));
1293
+
1294
+ async function lockWait(ms) {
1295
+ for (let t = 0; t <= ms; t += 500) {
1296
+ const release = lock();
1297
+ if (release) return release;
1298
+ await sleep(500);
1299
+ }
1300
+ return null;
1301
+ }
1302
+
1303
+ /* the target of a post by its media id, for a tap (the rule may be off: whoever got the button still gets the link) */
1304
+ function targetForMedia(media) {
1305
+ const dir = videosWithRule().find(d => mediaOf(d) === media);
1306
+ if (!dir) return null;
1307
+ return {
1308
+ name: nameOf(dir),
1309
+ post: postOf(dir),
1310
+ withRecord: async fn => {
1311
+ const release = await lockWait(60_000);
1312
+ if (!release) throw new Error("outra passada segurou o registro por mais de 1 min; o Meta repete o evento");
1313
+ try {
1314
+ const all = readJson(recordFile(dir)) ?? {};
1315
+ all.instagram ??= { media, comments: {} };
1316
+ if (await fn({ rule: ruleOf(dir), record: all.instagram })) writeJson(recordFile(dir), all);
1317
+ } finally {
1318
+ release();
1319
+ }
1320
+ },
1321
+ };
1322
+ }
1323
+
1324
+ async function webhook(sub) {
1325
+ const secret = env("INSTAGRAM_APP_SECRET");
1326
+ const verifyToken = env("INSTAGRAM_WEBHOOK_VERIFY_TOKEN");
1327
+ const port = +(option("port") ?? WEBHOOK_PORT);
1328
+ if (sub === "setup") {
1329
+ let token = verifyToken;
1330
+ if (!token) {
1331
+ token = randomBytes(16).toString("hex");
1332
+ writeEnv({ INSTAGRAM_WEBHOOK_VERIFY_TOKEN: token });
1333
+ }
1334
+ console.log(
1335
+ `${secret ? "✓" : "·"} INSTAGRAM_APP_SECRET no .env${secret ? "" : " (falta: o segredo do app Instagram, em API setup with Instagram login → App secret)"}`,
1336
+ );
1337
+ console.log("✓ INSTAGRAM_WEBHOOK_VERIFY_TOKEN no .env");
1338
+ console.log(`
1339
+ Faltam estes passos, uma vez só:
1340
+ 1. Expor a porta ${port}: node tools/omnichat.mjs webhook serve e, em outro terminal,
1341
+ cloudflared tunnel --url http://127.0.0.1:${port} (anote a URL https://….trycloudflare.com)
1342
+ 2. developers.facebook.com/apps → seu app → Instagram → API setup with Instagram login → Configure webhooks:
1343
+ Callback URL: <a URL do túnel>/webhook
1344
+ Verify token: ${token}
1345
+ e ligue os campos messages e messaging_postbacks.
1346
+ 3. node tools/omnichat.mjs webhook subscribe (inscreve a conta nesses campos)
1347
+ 4. O app precisa estar Live, com Advanced Access em instagram_business_manage_messages para quem não tem papel no app.`);
1348
+ return;
1349
+ }
1350
+ if (sub === "subscribe") {
1351
+ const { token, id } = await instagramToken();
1352
+ await igClient(token).subscribeWebhook(id, WEBHOOK_FIELDS);
1353
+ console.log(`conta inscrita em: ${WEBHOOK_FIELDS.join(", ")}`);
1354
+ return;
1355
+ }
1356
+ if (sub === "status") {
1357
+ const gated = gatedRules();
1358
+ console.log(`${secret ? "✓" : "·"} INSTAGRAM_APP_SECRET`);
1359
+ console.log(
1360
+ `${verifyToken ? "✓" : "·"} INSTAGRAM_WEBHOOK_VERIFY_TOKEN${verifyToken ? "" : " (webhook setup cria)"}`,
1361
+ );
1362
+ try {
1363
+ const { token, id } = await instagramToken();
1364
+ const fields = await igClient(token).webhookFields(id);
1365
+ const missing = WEBHOOK_FIELDS.filter(f => !fields.includes(f));
1366
+ console.log(
1367
+ `${missing.length ? "·" : "✓"} conta inscrita em: ${fields.join(", ") || "nada"}${missing.length ? ` (falta ${missing.join(", ")}: webhook subscribe)` : ""}`,
1368
+ );
1369
+ } catch (e) {
1370
+ console.log(`· inscrição da conta: ${e.message}`);
1371
+ }
1372
+ console.log(` regras com botão: ${gated.length ? gated.map(nameOf).join(", ") : "nenhuma"}`);
1373
+ return;
1374
+ }
1375
+ if (sub === "serve") {
1376
+ if (!secret || !verifyToken)
1377
+ fail(
1378
+ "faltam INSTAGRAM_APP_SECRET e INSTAGRAM_WEBHOOK_VERIFY_TOKEN no .env: node tools/omnichat.mjs webhook setup",
1379
+ );
1380
+ const account = async () => {
1381
+ const { token, id } = await instagramToken();
1382
+ return { client: igClient(token), id };
1383
+ };
1384
+ const server = createServer(async (req, res) => {
1385
+ const url = new URL(req.url ?? "/", "http://localhost");
1386
+ if (url.pathname !== "/webhook" && url.pathname !== "/") {
1387
+ res.writeHead(404).end();
1388
+ return;
1389
+ }
1390
+ const chunks = [];
1391
+ for await (const c of req) chunks.push(c);
1392
+ const out = await handleWebhook({
1393
+ method: req.method ?? "GET",
1394
+ query: Object.fromEntries(url.searchParams),
1395
+ raw: Buffer.concat(chunks).toString("utf8"),
1396
+ signature: req.headers["x-hub-signature-256"],
1397
+ secret,
1398
+ verifyToken,
1399
+ resolveTarget: async media => targetForMedia(media),
1400
+ account,
1401
+ C,
1402
+ log,
1403
+ warn: line => console.error(line),
1404
+ });
1405
+ res.writeHead(out.status, { "content-type": "text/plain; charset=utf-8" }).end(out.body);
1406
+ });
1407
+ server.listen(port, "127.0.0.1", () =>
1408
+ console.log(`webhook do omnichat ouvindo em http://127.0.0.1:${port}/webhook (exponha com um túnel HTTPS)`),
1409
+ );
1410
+ await new Promise(() => {});
1411
+ }
1412
+ fail("webhook setup | subscribe | status | serve [--port 5434]");
1413
+ }
1414
+
1415
+ /* --- the launchd agent ------------------------------------------------------------------
1416
+ Runs `run` every 60 s with the node that installed it. Does not wake the Mac: while asleep,
1417
+ comments wait and are replied to when it wakes, within the 7 days. */
1418
+ const domain = () => `gui/${process.getuid()}`;
1419
+ const launchctl = (...a) => spawnSync("launchctl", a, { encoding: "utf8" });
1420
+
1421
+ function agentStatus() {
1422
+ const installed = existsSync(PLIST);
1423
+ let node = null;
1424
+ if (installed) {
1425
+ try {
1426
+ node =
1427
+ /<key>ProgramArguments<\/key>\s*<array>\s*<string>([^<]+)<\/string>/.exec(readFileSync(PLIST, "utf8"))?.[1] ??
1428
+ null;
1429
+ } catch {
1430
+ /* unreadable */
1431
+ }
1432
+ }
1433
+ return {
1434
+ installed,
1435
+ loaded: installed && launchctl("print", `${domain()}/${AGENT_LABEL}`).status === 0,
1436
+ node_ok: !!node && existsSync(node),
1437
+ log: LOG_FILE,
1438
+ };
1439
+ }
1440
+
1441
+ function installAgent() {
1442
+ const x = s => String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
1443
+ /* the shell PATH goes along: launchd starts without claude, which reads the context of uncertain comments */
1444
+ const path = [
1445
+ ...new Set([
1446
+ dirname(process.execPath),
1447
+ "/opt/homebrew/bin",
1448
+ "/usr/local/bin",
1449
+ join(homedir(), ".local", "bin"),
1450
+ ...(process.env.PATH ?? "").split(":"),
1451
+ "/usr/bin",
1452
+ "/bin",
1453
+ ]),
1454
+ ]
1455
+ .filter(Boolean)
1456
+ .join(":");
1457
+ mkdirSync(DIR, { recursive: true });
1458
+ mkdirSync(dirname(PLIST), { recursive: true });
1459
+ writeFileSync(
1460
+ PLIST,
1461
+ `<?xml version="1.0" encoding="UTF-8"?>
1462
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
1463
+ <plist version="1.0">
1464
+ <dict>
1465
+ <key>Label</key><string>${AGENT_LABEL}</string>
1466
+ <key>ProgramArguments</key>
1467
+ <array>
1468
+ <string>${x(process.execPath)}</string>
1469
+ <string>${x(SELF)}</string>
1470
+ <string>run</string>
1471
+ </array>
1472
+ <key>WorkingDirectory</key><string>${x(ROOT)}</string>
1473
+ <key>EnvironmentVariables</key>
1474
+ <dict><key>PATH</key><string>${x(path)}</string>${homeEnvPlist(x)}</dict>
1475
+ <key>StartInterval</key><integer>60</integer>
1476
+ <key>RunAtLoad</key><true/>
1477
+ <key>StandardOutPath</key><string>${x(LOG_FILE)}</string>
1478
+ <key>StandardErrorPath</key><string>${x(LOG_FILE)}</string>
1479
+ </dict>
1480
+ </plist>
1481
+ `,
1482
+ );
1483
+ launchctl("bootout", `${domain()}/${AGENT_LABEL}`);
1484
+ let r;
1485
+ for (let i = 0; i < 6; i++) {
1486
+ r = launchctl("bootstrap", domain(), PLIST);
1487
+ if (r.status === 0) break;
1488
+ spawnSync("sleep", ["0.5"]);
1489
+ }
1490
+ if (r.status !== 0) fail(`launchctl bootstrap: ${(r.stderr || r.stdout).trim()}`);
1491
+ const enabled = videosWithRule().filter(p => ruleOf(p)?.enabled).length;
1492
+ console.log(
1493
+ `agente do omnichat ligado: confere a cada minuto, com o studio aberto ou não (log em .omnichat/omnichat.log)${enabled ? "" : "; nenhuma regra ligada ainda"}`,
1494
+ );
1495
+ }
1496
+
1497
+ function uninstallAgent() {
1498
+ launchctl("bootout", `${domain()}/${AGENT_LABEL}`);
1499
+ try {
1500
+ unlinkSync(PLIST);
1501
+ } catch {
1502
+ /* was not there */
1503
+ }
1504
+ console.log(
1505
+ "agente do omnichat desligado: os comentários esperam até você ligar de novo (a DM vale até 7 dias depois do comentário)",
1506
+ );
1507
+ }
1508
+
1509
+ /* ============================================================================
1510
+ CLI only: the studio imports videoLink and narrationKeyword */
1511
+ const isMain = isMainModule(import.meta.url);
1512
+ if (isMain) {
1513
+ const [cmd, sub] = args;
1514
+ /* target: a video's dir (scenes.json), a content's carousel dir (carousel.json) or an outside post's */
1515
+ const isVideo = p =>
1516
+ p &&
1517
+ !p.startsWith("--") &&
1518
+ (existsSync(join(resolve(p), "scenes.json")) ||
1519
+ isCarousel(resolve(p)) ||
1520
+ (resolve(p).startsWith(POSTS + "/") && existsSync(join(resolve(p), "publication.json"))));
1521
+ try {
1522
+ if (cmd === "status") await status();
1523
+ else if (cmd === "rule" && isVideo(sub)) rule(resolve(sub));
1524
+ else if (cmd === "post") await post(sub);
1525
+ else if (cmd === "enable" && isVideo(sub)) enable(resolve(sub));
1526
+ else if (cmd === "disable" && isVideo(sub)) disable(resolve(sub));
1527
+ else if (cmd === "published" && isVideo(sub)) published(resolve(sub));
1528
+ else if (cmd === "test" && isVideo(sub)) await test(resolve(sub), args[2]);
1529
+ else if (cmd === "retry" && isVideo(sub)) await retry(resolve(sub));
1530
+ else if (cmd === "comments" && isVideo(sub)) await comments(resolve(sub));
1531
+ else if (cmd === "delete" && isVideo(sub))
1532
+ await remove(
1533
+ resolve(sub),
1534
+ args.slice(2).filter(a => !a.startsWith("--")),
1535
+ );
1536
+ else if (cmd === "send" && isVideo(sub))
1537
+ await send(
1538
+ resolve(sub),
1539
+ args.slice(2).filter((a, i, l) => !a.startsWith("--") && l[i - 1] !== "--public-reply"),
1540
+ );
1541
+ else if (cmd === "run") await run(isVideo(sub) ? resolve(sub) : null);
1542
+ else if (cmd === "log" && isVideo(sub)) await showLog(resolve(sub));
1543
+ else if (cmd === "webhook") await webhook(sub);
1544
+ else if (cmd === "cloud") await cloudCmd(sub);
1545
+ else if (cmd === "agent" && (!sub || sub === "--json")) {
1546
+ const a = agentStatus();
1547
+ /* the cloud answering is what the studio shows instead of the local agent */
1548
+ if (cloudOwns()) a.cloud = await cloudHealth();
1549
+ if (has("json")) console.log(JSON.stringify(a));
1550
+ else if (a.cloud)
1551
+ console.log(
1552
+ `quem responde: a cloud${a.cloud.ready ? (a.cloud.active ? "" : " (desligada: cloud push)") : ` (sem resposta: ${a.cloud.reason})`}${a.cloud.pause ? ` · em pausa até ${dayTime(a.cloud.pause.until)}: ${a.cloud.pause.reason}` : ""}`,
1553
+ );
1554
+ else
1555
+ console.log(
1556
+ `agente: ${a.loaded ? "ligado, confere a cada minuto" : "desligado (agent install)"}${a.installed && !a.node_ok ? " · o node do agente sumiu: rode agent install de novo" : ""}`,
1557
+ );
1558
+ } else if (cmd === "agent" && sub === "install") installAgent();
1559
+ else if (cmd === "agent" && sub === "uninstall") uninstallAgent();
1560
+ else {
1561
+ const source = readFileSync(SELF, "utf8").split("\n");
1562
+ console.log(
1563
+ source
1564
+ .slice(
1565
+ 1,
1566
+ source.findIndex((l, i) => i > 1 && /^\s*=+\s*\*\/\s*$/.test(l)),
1567
+ )
1568
+ .join("\n"),
1569
+ );
1570
+ process.exit(2);
1571
+ }
1572
+ /* a rule saved while the cloud answers goes up before the CLI exits */
1573
+ await flushCloud();
1574
+ } catch (e) {
1575
+ fail(e.message);
1576
+ }
1577
+ }