@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.
- package/.env.example +41 -0
- package/README.md +2 -2
- package/bin/omni.mjs +18 -0
- package/docs/onboarding/clone-voice.png +0 -0
- package/docs/onboarding/getting-started.png +0 -0
- package/docs/onboarding/new-video.png +0 -0
- package/docs/onboarding/providers.png +0 -0
- package/docs/onboarding/publish.png +0 -0
- package/docs/onboarding/references.png +0 -0
- package/docs/onboarding.md +212 -0
- package/omnivideo.config.example.json +437 -0
- package/package.json +40 -4
- package/reference/captions.md +156 -0
- package/reference/carousel-contract.md +91 -0
- package/reference/cloud.md +96 -0
- package/reference/learnings.md +792 -0
- package/reference/omnichat.md +305 -0
- package/reference/premiere-mcp.md +91 -0
- package/reference/publish.md +305 -0
- package/reference/scenes-contract.md +109 -0
- package/reference/studio.md +666 -0
- package/reference/voicestudio.md +81 -0
- package/reference/writing-for-voice.md +67 -0
- package/requirements.txt +3 -0
- package/studio/api/app.ts +87 -0
- package/studio/api/lib/errors.ts +33 -0
- package/studio/api/lib/files.ts +41 -0
- package/studio/api/lib/hono.ts +9 -0
- package/studio/api/lib/result.ts +14 -0
- package/studio/api/lib/validate.ts +31 -0
- package/studio/api/lib/video.ts +10 -0
- package/studio/api/middleware/json-charset.ts +11 -0
- package/studio/api/middleware/local-only.ts +32 -0
- package/studio/api/middleware/no-timeout.ts +10 -0
- package/studio/api/routes/admin.ts +18 -0
- package/studio/api/routes/agent-cascade.ts +100 -0
- package/studio/api/routes/agent-history.ts +23 -0
- package/studio/api/routes/agent.ts +162 -0
- package/studio/api/routes/analytics.ts +31 -0
- package/studio/api/routes/approval.ts +60 -0
- package/studio/api/routes/archive.ts +12 -0
- package/studio/api/routes/captions.ts +28 -0
- package/studio/api/routes/carousel.ts +38 -0
- package/studio/api/routes/clone.ts +27 -0
- package/studio/api/routes/create.ts +19 -0
- package/studio/api/routes/events.ts +11 -0
- package/studio/api/routes/export.ts +14 -0
- package/studio/api/routes/media.ts +30 -0
- package/studio/api/routes/omnichat.ts +113 -0
- package/studio/api/routes/onboarding.ts +14 -0
- package/studio/api/routes/publish.ts +40 -0
- package/studio/api/routes/queue.ts +14 -0
- package/studio/api/routes/references.ts +40 -0
- package/studio/api/routes/runner.ts +17 -0
- package/studio/api/routes/schedule.ts +47 -0
- package/studio/api/routes/services.ts +11 -0
- package/studio/api/routes/sessions.ts +27 -0
- package/studio/api/routes/settings.ts +102 -0
- package/studio/api/routes/sound.ts +45 -0
- package/studio/api/routes/thumbnail.ts +18 -0
- package/studio/api/routes/usage.ts +13 -0
- package/studio/api/routes/videos.ts +41 -0
- package/studio/api/routes/voices.ts +71 -0
- package/studio/api/services/admin.ts +113 -0
- package/studio/api/services/agent-cascade.ts +975 -0
- package/studio/api/services/agent-claude.ts +47 -0
- package/studio/api/services/agent-history.ts +103 -0
- package/studio/api/services/agent-persona.ts +77 -0
- package/studio/api/services/agent-tools.ts +2237 -0
- package/studio/api/services/agent-ui-tools.ts +259 -0
- package/studio/api/services/agent-wait.ts +32 -0
- package/studio/api/services/agent.ts +553 -0
- package/studio/api/services/analytics.ts +92 -0
- package/studio/api/services/approval.ts +134 -0
- package/studio/api/services/archive.ts +38 -0
- package/studio/api/services/captions.ts +268 -0
- package/studio/api/services/carousel.ts +206 -0
- package/studio/api/services/classify.ts +196 -0
- package/studio/api/services/clone-phrases.ts +75 -0
- package/studio/api/services/clone.ts +722 -0
- package/studio/api/services/create.ts +421 -0
- package/studio/api/services/events.ts +215 -0
- package/studio/api/services/media.ts +32 -0
- package/studio/api/services/omnichat.ts +624 -0
- package/studio/api/services/onboarding.ts +77 -0
- package/studio/api/services/publish.ts +287 -0
- package/studio/api/services/queue.ts +150 -0
- package/studio/api/services/references.ts +269 -0
- package/studio/api/services/root.ts +60 -0
- package/studio/api/services/services.ts +114 -0
- package/studio/api/services/sessions.ts +1007 -0
- package/studio/api/services/settings.ts +983 -0
- package/studio/api/services/sound.ts +311 -0
- package/studio/api/services/thumbnail.ts +133 -0
- package/studio/api/services/usage.ts +27 -0
- package/studio/api/services/videos.ts +418 -0
- package/studio/api/services/voice-pause.ts +98 -0
- package/studio/api/services/voices.ts +324 -0
- package/studio/runner/runner.ts +244 -0
- package/studio/shared/omni-actions.ts +68 -0
- package/studio/shared/omni-admin.ts +52 -0
- package/studio/shared/omni-farewell.ts +48 -0
- package/studio/shared/omni-hotword.ts +20 -0
- package/studio/shared/omni-screens.ts +228 -0
- package/studio/shared/schemas/admin.ts +15 -0
- package/studio/shared/schemas/agent-cascade.ts +58 -0
- package/studio/shared/schemas/agent-history.ts +73 -0
- package/studio/shared/schemas/agent.ts +50 -0
- package/studio/shared/schemas/analytics.ts +6 -0
- package/studio/shared/schemas/approval.ts +22 -0
- package/studio/shared/schemas/captions.ts +19 -0
- package/studio/shared/schemas/carousel.ts +26 -0
- package/studio/shared/schemas/clone.ts +40 -0
- package/studio/shared/schemas/common.ts +16 -0
- package/studio/shared/schemas/omnichat.ts +54 -0
- package/studio/shared/schemas/onboarding.ts +5 -0
- package/studio/shared/schemas/publish.ts +45 -0
- package/studio/shared/schemas/references.ts +20 -0
- package/studio/shared/schemas/settings.ts +82 -0
- package/studio/shared/schemas/sound.ts +22 -0
- package/studio/shared/schemas/videos.ts +38 -0
- package/studio/shared/schemas/voices.ts +23 -0
- package/studio/shared/types/api.ts +1290 -0
- package/studio/studio.ts +148 -0
- package/studio/tsconfig.json +15 -0
- package/studio/web/assets/logos/cartesia.svg +4 -0
- package/studio/web/assets/logos/elevenlabs.svg +12 -0
- package/studio/web/assets/logos/fish.svg +24 -0
- package/studio/web/assets/logos/omni-black.png +0 -0
- package/studio/web/assets/logos/omni-white.png +0 -0
- package/studio/web/assets/logos/voicestudio.svg +21 -0
- package/studio/web/components/AgentMark.tsx +7 -0
- package/studio/web/components/AgentWalking.tsx +11 -0
- package/studio/web/components/ApiKeyField.tsx +104 -0
- package/studio/web/components/AppShell.tsx +64 -0
- package/studio/web/components/ApprovalNotice.tsx +65 -0
- package/studio/web/components/AvatarGroupIcon.tsx +22 -0
- package/studio/web/components/AvatarNameField.tsx +86 -0
- package/studio/web/components/AvatarPicker.tsx +84 -0
- package/studio/web/components/BrandLogo.tsx +16 -0
- package/studio/web/components/CartesiaPlanSetting.tsx +90 -0
- package/studio/web/components/ClaudeWalking.tsx +32 -0
- package/studio/web/components/CloneVoiceDialog/GuidedScript.tsx +39 -0
- package/studio/web/components/CloneVoiceDialog/LanguageBar.tsx +37 -0
- package/studio/web/components/CloneVoiceDialog/LevelMeter.tsx +31 -0
- package/studio/web/components/CloneVoiceDialog/ProviderChoice.tsx +81 -0
- package/studio/web/components/CloneVoiceDialog/QualityPanel.tsx +62 -0
- package/studio/web/components/CloneVoiceDialog/RecordStep.tsx +50 -0
- package/studio/web/components/CloneVoiceDialog/RecorderBar.tsx +79 -0
- package/studio/web/components/CloneVoiceDialog/RedoSummary.tsx +54 -0
- package/studio/web/components/CloneVoiceDialog/SaveStep.tsx +91 -0
- package/studio/web/components/CloneVoiceDialog/SourceBar.tsx +30 -0
- package/studio/web/components/CloneVoiceDialog/index.tsx +141 -0
- package/studio/web/components/CloneVoiceDialog/types.ts +28 -0
- package/studio/web/components/CloneVoiceDialog/useCloneVoice.ts +528 -0
- package/studio/web/components/ConnectionSignals.tsx +51 -0
- package/studio/web/components/EditVoiceDialog/EditVoiceFooter.tsx +72 -0
- package/studio/web/components/EditVoiceDialog/ProviderCards.tsx +81 -0
- package/studio/web/components/EditVoiceDialog/index.tsx +256 -0
- package/studio/web/components/ErrorBanner.tsx +115 -0
- package/studio/web/components/FinishedTask.tsx +41 -0
- package/studio/web/components/FishModelSetting.tsx +69 -0
- package/studio/web/components/FishPriceSetting.tsx +44 -0
- package/studio/web/components/HumanBanner.tsx +64 -0
- package/studio/web/components/Lightbox.tsx +41 -0
- package/studio/web/components/LogStream.tsx +42 -0
- package/studio/web/components/Markdown.tsx +21 -0
- package/studio/web/components/MissingDmsPanel/BatchSend.tsx +137 -0
- package/studio/web/components/MissingDmsPanel/CommentCard.tsx +168 -0
- package/studio/web/components/MissingDmsPanel/CommentFeed.tsx +45 -0
- package/studio/web/components/MissingDmsPanel/DmBubble.tsx +38 -0
- package/studio/web/components/MissingDmsPanel/ReplyComposer.tsx +63 -0
- package/studio/web/components/MissingDmsPanel/index.tsx +98 -0
- package/studio/web/components/NotFound.tsx +48 -0
- package/studio/web/components/Omni/OmniAvatarPicker.tsx +92 -0
- package/studio/web/components/Omni/OmniCards.tsx +202 -0
- package/studio/web/components/Omni/OmniGlow.tsx +183 -0
- package/studio/web/components/Omni/OmniHistory.tsx +119 -0
- package/studio/web/components/Omni/OmniHotword.tsx +40 -0
- package/studio/web/components/Omni/OmniMicSelect.tsx +92 -0
- package/studio/web/components/Omni/OmniVoiceSample.tsx +81 -0
- package/studio/web/components/Omni/OmniWidget.tsx +743 -0
- package/studio/web/components/Omni/PendingActions.tsx +200 -0
- package/studio/web/components/Omni/Transcript.tsx +127 -0
- package/studio/web/components/OmnichatAccountStatus.tsx +87 -0
- package/studio/web/components/OmnichatRulePanel/RuleConfig.tsx +150 -0
- package/studio/web/components/OmnichatRulePanel/RuleDryRun.tsx +15 -0
- package/studio/web/components/OmnichatRulePanel/RuleEnableConfirm.tsx +42 -0
- package/studio/web/components/OmnichatRulePanel/RuleSkeleton.tsx +18 -0
- package/studio/web/components/OmnichatRulePanel/RuleStatus.tsx +55 -0
- package/studio/web/components/OmnichatRulePanel/index.tsx +115 -0
- package/studio/web/components/OmnichatRulePanel/rule-steps.ts +67 -0
- package/studio/web/components/OmnichatRulePanel/steps/MessageStep.tsx +91 -0
- package/studio/web/components/OmnichatRulePanel/steps/RepliesStep.tsx +201 -0
- package/studio/web/components/OmnichatRulePanel/steps/TriggerStep.tsx +76 -0
- package/studio/web/components/OmnichatRulePanel/steps/types.ts +11 -0
- package/studio/web/components/Palette.tsx +17 -0
- package/studio/web/components/PlatformCard.tsx +109 -0
- package/studio/web/components/PlatformIcon.tsx +19 -0
- package/studio/web/components/ProviderCard.tsx +80 -0
- package/studio/web/components/ProviderDetail.tsx +367 -0
- package/studio/web/components/ProviderLogo.tsx +20 -0
- package/studio/web/components/QueuePanel.tsx +77 -0
- package/studio/web/components/ReferenceActions.tsx +89 -0
- package/studio/web/components/ReferencePicker.tsx +136 -0
- package/studio/web/components/ResultMessage.tsx +7 -0
- package/studio/web/components/RuleForm.tsx +138 -0
- package/studio/web/components/RuleLog.tsx +151 -0
- package/studio/web/components/RunningTask.tsx +23 -0
- package/studio/web/components/SampleList.tsx +55 -0
- package/studio/web/components/SessionPanel.tsx +352 -0
- package/studio/web/components/SettingRow.tsx +14 -0
- package/studio/web/components/SettingsCategorySkeleton.tsx +25 -0
- package/studio/web/components/SidePanel.tsx +77 -0
- package/studio/web/components/Sidebar.tsx +289 -0
- package/studio/web/components/StageStepper.tsx +87 -0
- package/studio/web/components/StudioOffline.tsx +80 -0
- package/studio/web/components/TaskName.tsx +17 -0
- package/studio/web/components/TopBanners.tsx +30 -0
- package/studio/web/components/VideoOmnichatPanel.tsx +287 -0
- package/studio/web/components/VoiceAvatar.tsx +33 -0
- package/studio/web/components/VoiceCarousel.tsx +196 -0
- package/studio/web/components/VoiceCategory.tsx +9 -0
- package/studio/web/components/VoiceModal.tsx +39 -0
- package/studio/web/components/VoiceProfileCard.tsx +75 -0
- package/studio/web/components/VoiceProfilePicker.tsx +107 -0
- package/studio/web/components/VoiceProfilesSettings.tsx +150 -0
- package/studio/web/components/VoiceProviderCard.tsx +118 -0
- package/studio/web/components/VoiceProvidersCard.tsx +26 -0
- package/studio/web/components/WaitList.tsx +21 -0
- package/studio/web/components/icons.tsx +247 -0
- package/studio/web/components/ui/accordion.tsx +47 -0
- package/studio/web/components/ui/badge.tsx +18 -0
- package/studio/web/components/ui/calendar.tsx +43 -0
- package/studio/web/components/ui/chart.tsx +229 -0
- package/studio/web/components/ui/checkbox.tsx +26 -0
- package/studio/web/components/ui/cx.ts +2 -0
- package/studio/web/components/ui/date-time-picker.tsx +84 -0
- package/studio/web/components/ui/panel.tsx +23 -0
- package/studio/web/components/ui/picker.tsx +58 -0
- package/studio/web/components/ui/popover.tsx +24 -0
- package/studio/web/components/ui/select.tsx +89 -0
- package/studio/web/components/ui/skeleton.tsx +10 -0
- package/studio/web/components/ui/slider.tsx +68 -0
- package/studio/web/components/ui/switch.tsx +13 -0
- package/studio/web/components/ui/tooltip.tsx +59 -0
- package/studio/web/hooks/useAccounts.ts +27 -0
- package/studio/web/hooks/useAdmin.ts +12 -0
- package/studio/web/hooks/useAnalytics.ts +21 -0
- package/studio/web/hooks/useAvatarAudioReactive.ts +87 -0
- package/studio/web/hooks/useBrowserConnect.ts +48 -0
- package/studio/web/hooks/useClonePlan.ts +14 -0
- package/studio/web/hooks/useConnected.ts +7 -0
- package/studio/web/hooks/useLiveQuery.ts +29 -0
- package/studio/web/hooks/useMenuCollapsed.ts +32 -0
- package/studio/web/hooks/useMicDevices.ts +53 -0
- package/studio/web/hooks/useMissingDms.ts +211 -0
- package/studio/web/hooks/useMissingSetupSteps.ts +26 -0
- package/studio/web/hooks/useNow.ts +11 -0
- package/studio/web/hooks/useOmniAgent.ts +278 -0
- package/studio/web/hooks/useOmniClaude.ts +124 -0
- package/studio/web/hooks/useOmniDock.ts +142 -0
- package/studio/web/hooks/useOmniGreeting.ts +60 -0
- package/studio/web/hooks/useOmniHotword.ts +128 -0
- package/studio/web/hooks/useOmnichat.ts +26 -0
- package/studio/web/hooks/useOnboarding.ts +27 -0
- package/studio/web/hooks/usePost.ts +23 -0
- package/studio/web/hooks/useQueue.ts +10 -0
- package/studio/web/hooks/useReferences.ts +20 -0
- package/studio/web/hooks/useRuleEditor.ts +101 -0
- package/studio/web/hooks/useRunner.ts +24 -0
- package/studio/web/hooks/useSaveAction.ts +23 -0
- package/studio/web/hooks/useSchedule.ts +11 -0
- package/studio/web/hooks/useServices.ts +10 -0
- package/studio/web/hooks/useSessions.ts +15 -0
- package/studio/web/hooks/useSettings.ts +11 -0
- package/studio/web/hooks/useSettingsSection.ts +30 -0
- package/studio/web/hooks/useSidePanel.ts +23 -0
- package/studio/web/hooks/useSound.ts +30 -0
- package/studio/web/hooks/useUsage.ts +11 -0
- package/studio/web/hooks/useVideoVoice.ts +36 -0
- package/studio/web/hooks/useVideos.ts +21 -0
- package/studio/web/hooks/useVoiceProfilePreview.ts +52 -0
- package/studio/web/hooks/useVoiceProfiles.ts +7 -0
- package/studio/web/hooks/useVoiceRunning.ts +10 -0
- package/studio/web/hooks/useVoiceStudioClosed.ts +8 -0
- package/studio/web/index.html +19 -0
- package/studio/web/lib/agent.ts +7 -0
- package/studio/web/lib/alerts.ts +210 -0
- package/studio/web/lib/analytics-metrics.ts +351 -0
- package/studio/web/lib/api.ts +70 -0
- package/studio/web/lib/audio.ts +39 -0
- package/studio/web/lib/avatars.ts +41 -0
- package/studio/web/lib/calendar.ts +218 -0
- package/studio/web/lib/caption-position.ts +249 -0
- package/studio/web/lib/carousel.ts +24 -0
- package/studio/web/lib/chime.ts +42 -0
- package/studio/web/lib/clone-voice.ts +55 -0
- package/studio/web/lib/export.ts +11 -0
- package/studio/web/lib/files.ts +8 -0
- package/studio/web/lib/format.ts +93 -0
- package/studio/web/lib/media.ts +29 -0
- package/studio/web/lib/mic-device.ts +72 -0
- package/studio/web/lib/missing-dms.ts +70 -0
- package/studio/web/lib/omni-cards.ts +42 -0
- package/studio/web/lib/omni-cascade.ts +1067 -0
- package/studio/web/lib/omni-greeting-audio.ts +24 -0
- package/studio/web/lib/omni-hotword.ts +231 -0
- package/studio/web/lib/omni-panel.ts +67 -0
- package/studio/web/lib/omni-realtime.ts +811 -0
- package/studio/web/lib/omni-ui.ts +402 -0
- package/studio/web/lib/omni-wait.ts +174 -0
- package/studio/web/lib/omnichat-api.ts +44 -0
- package/studio/web/lib/orb.ts +270 -0
- package/studio/web/lib/pcm-recorder.ts +115 -0
- package/studio/web/lib/platforms.ts +17 -0
- package/studio/web/lib/providers.ts +27 -0
- package/studio/web/lib/publish-draft.ts +52 -0
- package/studio/web/lib/publish-settings.ts +81 -0
- package/studio/web/lib/query-client.ts +122 -0
- package/studio/web/lib/references.ts +13 -0
- package/studio/web/lib/services.ts +40 -0
- package/studio/web/lib/sessions.ts +103 -0
- package/studio/web/lib/settings-categories.ts +58 -0
- package/studio/web/lib/sound-audio.ts +33 -0
- package/studio/web/lib/sound-timeline.ts +291 -0
- package/studio/web/lib/sse.ts +86 -0
- package/studio/web/lib/stage-history.ts +141 -0
- package/studio/web/lib/stages.ts +344 -0
- package/studio/web/lib/studio-offline.ts +60 -0
- package/studio/web/lib/upload.ts +24 -0
- package/studio/web/lib/video-tabs.ts +118 -0
- package/studio/web/lib/voice-profile-draft.ts +34 -0
- package/studio/web/lib/voice-profiles.ts +3 -0
- package/studio/web/main.tsx +14 -0
- package/studio/web/pages/AdminPage/index.tsx +123 -0
- package/studio/web/pages/AgentPage/components/Measurements.tsx +102 -0
- package/studio/web/pages/AgentPage/index.tsx +28 -0
- package/studio/web/pages/AnalyticsPage/components/About.tsx +52 -0
- package/studio/web/pages/AnalyticsPage/components/ChannelCards.tsx +106 -0
- package/studio/web/pages/AnalyticsPage/components/ChannelView.tsx +137 -0
- package/studio/web/pages/AnalyticsPage/components/CollectionStatus.tsx +46 -0
- package/studio/web/pages/AnalyticsPage/components/Comparison.tsx +68 -0
- package/studio/web/pages/AnalyticsPage/components/Diagnosis.tsx +50 -0
- package/studio/web/pages/AnalyticsPage/components/Highlights.tsx +49 -0
- package/studio/web/pages/AnalyticsPage/components/Hint.tsx +11 -0
- package/studio/web/pages/AnalyticsPage/components/HookVsBody.tsx +100 -0
- package/studio/web/pages/AnalyticsPage/components/HourlyWave.tsx +133 -0
- package/studio/web/pages/AnalyticsPage/components/Insights.tsx +70 -0
- package/studio/web/pages/AnalyticsPage/components/MetricCard.tsx +46 -0
- package/studio/web/pages/AnalyticsPage/components/PlanSection.tsx +40 -0
- package/studio/web/pages/AnalyticsPage/components/ProgressionChart.tsx +155 -0
- package/studio/web/pages/AnalyticsPage/components/ReelBarsChart.tsx +154 -0
- package/studio/web/pages/AnalyticsPage/components/ReelThumb.tsx +19 -0
- package/studio/web/pages/AnalyticsPage/components/ReelView.tsx +290 -0
- package/studio/web/pages/AnalyticsPage/components/ReelsTable.tsx +269 -0
- package/studio/web/pages/AnalyticsPage/components/RequestWithFocus.tsx +38 -0
- package/studio/web/pages/AnalyticsPage/components/SegmentedControl.tsx +35 -0
- package/studio/web/pages/AnalyticsPage/components/VsMedian.tsx +31 -0
- package/studio/web/pages/AnalyticsPage/index.tsx +17 -0
- package/studio/web/pages/GettingStartedPage/index.tsx +174 -0
- package/studio/web/pages/NewVideoPage/components/DeliverablesPicker.tsx +62 -0
- package/studio/web/pages/NewVideoPage/components/FormatPicker.tsx +33 -0
- package/studio/web/pages/NewVideoPage/index.tsx +133 -0
- package/studio/web/pages/NotFoundPage/index.tsx +9 -0
- package/studio/web/pages/OmnichatPage/components/NewFromLink.tsx +59 -0
- package/studio/web/pages/OmnichatPage/components/RuleListItem.tsx +64 -0
- package/studio/web/pages/OmnichatPage/components/RulePreview.tsx +14 -0
- package/studio/web/pages/OmnichatPage/components/RuleView.tsx +88 -0
- package/studio/web/pages/OmnichatPage/index.tsx +153 -0
- package/studio/web/pages/QueuePage/index.tsx +22 -0
- package/studio/web/pages/ReferencePage/components/ReferenceTexts.tsx +131 -0
- package/studio/web/pages/ReferencePage/index.tsx +216 -0
- package/studio/web/pages/ReferencesPage/components/NewReferenceForm.tsx +150 -0
- package/studio/web/pages/ReferencesPage/components/ReferenceCard.tsx +42 -0
- package/studio/web/pages/ReferencesPage/components/ReferenceGridSkeleton.tsx +31 -0
- package/studio/web/pages/ReferencesPage/index.tsx +67 -0
- package/studio/web/pages/SchedulePage/components/CalendarSkeleton.tsx +18 -0
- package/studio/web/pages/SchedulePage/components/DayView.tsx +177 -0
- package/studio/web/pages/SchedulePage/components/HourPicker.tsx +103 -0
- package/studio/web/pages/SchedulePage/components/MonthDayCell.tsx +111 -0
- package/studio/web/pages/SchedulePage/components/MonthView.tsx +47 -0
- package/studio/web/pages/SchedulePage/components/PlatformIcons.tsx +12 -0
- package/studio/web/pages/SchedulePage/components/PostThumb.tsx +15 -0
- package/studio/web/pages/SchedulePage/components/WeekColumn.tsx +75 -0
- package/studio/web/pages/SchedulePage/components/WeekView.tsx +35 -0
- package/studio/web/pages/SchedulePage/index.tsx +285 -0
- package/studio/web/pages/SettingsPage/components/AgentAccounts.tsx +263 -0
- package/studio/web/pages/SettingsPage/components/AgentCategory.tsx +20 -0
- package/studio/web/pages/SettingsPage/components/ModelPicker.tsx +49 -0
- package/studio/web/pages/SettingsPage/components/ModelsSettings.tsx +138 -0
- package/studio/web/pages/SettingsPage/components/PlatformsCategory.tsx +23 -0
- package/studio/web/pages/SettingsPage/components/PublishingCategory/AccountStatus.tsx +29 -0
- package/studio/web/pages/SettingsPage/components/PublishingCategory/AutoPublishPanel.tsx +90 -0
- package/studio/web/pages/SettingsPage/components/PublishingCategory/BrowserConnect.tsx +62 -0
- package/studio/web/pages/SettingsPage/components/PublishingCategory/CheckField.tsx +19 -0
- package/studio/web/pages/SettingsPage/components/PublishingCategory/InstagramPanel.tsx +113 -0
- package/studio/web/pages/SettingsPage/components/PublishingCategory/OmnichatSettingsPanel.tsx +146 -0
- package/studio/web/pages/SettingsPage/components/PublishingCategory/SaveBar.tsx +33 -0
- package/studio/web/pages/SettingsPage/components/PublishingCategory/SchedulePanel.tsx +132 -0
- package/studio/web/pages/SettingsPage/components/PublishingCategory/SettingsCard.tsx +47 -0
- package/studio/web/pages/SettingsPage/components/PublishingCategory/SettingsCardGroup.tsx +19 -0
- package/studio/web/pages/SettingsPage/components/PublishingCategory/TextPanel.tsx +55 -0
- package/studio/web/pages/SettingsPage/components/PublishingCategory/TiktokPanel.tsx +123 -0
- package/studio/web/pages/SettingsPage/components/PublishingCategory/YoutubePanel.tsx +100 -0
- package/studio/web/pages/SettingsPage/components/PublishingCategory/badges.tsx +15 -0
- package/studio/web/pages/SettingsPage/components/PublishingCategory/index.tsx +40 -0
- package/studio/web/pages/SettingsPage/components/PublishingCategory/types.ts +10 -0
- package/studio/web/pages/SettingsPage/components/UsageCategory.tsx +389 -0
- package/studio/web/pages/SettingsPage/components/VoiceProvidersCategory.tsx +97 -0
- package/studio/web/pages/SettingsPage/index.tsx +85 -0
- package/studio/web/pages/VideoPage/components/AudioPlayer.tsx +113 -0
- package/studio/web/pages/VideoPage/components/CaptionPositionEditor/AxisField.tsx +50 -0
- package/studio/web/pages/VideoPage/components/CaptionPositionEditor/CaptionList.tsx +56 -0
- package/studio/web/pages/VideoPage/components/CaptionPositionEditor/CaptionListItem.tsx +92 -0
- package/studio/web/pages/VideoPage/components/CaptionPositionEditor/CaptionMargins.tsx +57 -0
- package/studio/web/pages/VideoPage/components/CaptionPositionEditor/CaptionStyleFields.tsx +70 -0
- package/studio/web/pages/VideoPage/components/CaptionPositionEditor/CaptionTextForm.tsx +87 -0
- package/studio/web/pages/VideoPage/components/CaptionPositionEditor/ColorField.tsx +37 -0
- package/studio/web/pages/VideoPage/components/CaptionPositionEditor/MarginField.tsx +59 -0
- package/studio/web/pages/VideoPage/components/CaptionPositionEditor/NumberField.tsx +57 -0
- package/studio/web/pages/VideoPage/components/CaptionPositionEditor/PositionControls.tsx +140 -0
- package/studio/web/pages/VideoPage/components/CaptionPositionEditor/captionEditor.ts +215 -0
- package/studio/web/pages/VideoPage/components/CaptionPositionEditor/useCaptionPosition.ts +154 -0
- package/studio/web/pages/VideoPage/components/CarouselChangeRequest.tsx +72 -0
- package/studio/web/pages/VideoPage/components/CarouselCompositionStage.tsx +80 -0
- package/studio/web/pages/VideoPage/components/CarouselCreate.tsx +63 -0
- package/studio/web/pages/VideoPage/components/CarouselMissing.tsx +19 -0
- package/studio/web/pages/VideoPage/components/CarouselPublishStage.tsx +173 -0
- package/studio/web/pages/VideoPage/components/CarouselScriptStage.tsx +82 -0
- package/studio/web/pages/VideoPage/components/CarouselSlideCard.tsx +49 -0
- package/studio/web/pages/VideoPage/components/CarouselViewer.tsx +55 -0
- package/studio/web/pages/VideoPage/components/ChangedVoices.tsx +53 -0
- package/studio/web/pages/VideoPage/components/CompositionStage.tsx +78 -0
- package/studio/web/pages/VideoPage/components/CoverPicker.tsx +32 -0
- package/studio/web/pages/VideoPage/components/EditorStage.tsx +53 -0
- package/studio/web/pages/VideoPage/components/LineTakes.tsx +160 -0
- package/studio/web/pages/VideoPage/components/LineTextEditor.tsx +124 -0
- package/studio/web/pages/VideoPage/components/LineVoicePicker.tsx +37 -0
- package/studio/web/pages/VideoPage/components/MediaImage.tsx +25 -0
- package/studio/web/pages/VideoPage/components/MotionScriptStage.tsx +39 -0
- package/studio/web/pages/VideoPage/components/NarrationStage.tsx +70 -0
- package/studio/web/pages/VideoPage/components/PublishForm.tsx +387 -0
- package/studio/web/pages/VideoPage/components/PublishStage.tsx +76 -0
- package/studio/web/pages/VideoPage/components/PublishText.tsx +149 -0
- package/studio/web/pages/VideoPage/components/ScenesApprovalPanel.tsx +278 -0
- package/studio/web/pages/VideoPage/components/ScheduledInfo.tsx +72 -0
- package/studio/web/pages/VideoPage/components/ScriptApprovalPanel.tsx +189 -0
- package/studio/web/pages/VideoPage/components/ScriptStage.tsx +17 -0
- package/studio/web/pages/VideoPage/components/SequencePlayer.tsx +146 -0
- package/studio/web/pages/VideoPage/components/SoundTimeline/AddSound.tsx +117 -0
- package/studio/web/pages/VideoPage/components/SoundTimeline/AreaSelection.tsx +74 -0
- package/studio/web/pages/VideoPage/components/SoundTimeline/BedPanel.tsx +94 -0
- package/studio/web/pages/VideoPage/components/SoundTimeline/BedRow.tsx +45 -0
- package/studio/web/pages/VideoPage/components/SoundTimeline/CaptionOverlay.tsx +44 -0
- package/studio/web/pages/VideoPage/components/SoundTimeline/CaptionSaveBar.tsx +32 -0
- package/studio/web/pages/VideoPage/components/SoundTimeline/CaptionsPanel.tsx +22 -0
- package/studio/web/pages/VideoPage/components/SoundTimeline/ClipBlock.tsx +111 -0
- package/studio/web/pages/VideoPage/components/SoundTimeline/ClipPanel.tsx +76 -0
- package/studio/web/pages/VideoPage/components/SoundTimeline/CutsPanel.tsx +44 -0
- package/studio/web/pages/VideoPage/components/SoundTimeline/EditorTabBar.tsx +73 -0
- package/studio/web/pages/VideoPage/components/SoundTimeline/EffectChoice.tsx +46 -0
- package/studio/web/pages/VideoPage/components/SoundTimeline/EffectItem.tsx +89 -0
- package/studio/web/pages/VideoPage/components/SoundTimeline/EffectPanel.tsx +232 -0
- package/studio/web/pages/VideoPage/components/SoundTimeline/EffectsRow.tsx +21 -0
- package/studio/web/pages/VideoPage/components/SoundTimeline/Lane.tsx +39 -0
- package/studio/web/pages/VideoPage/components/SoundTimeline/MarkerRow.tsx +40 -0
- package/studio/web/pages/VideoPage/components/SoundTimeline/MuteButton.tsx +36 -0
- package/studio/web/pages/VideoPage/components/SoundTimeline/NumberInput.tsx +37 -0
- package/studio/web/pages/VideoPage/components/SoundTimeline/PlanWarnings.tsx +19 -0
- package/studio/web/pages/VideoPage/components/SoundTimeline/PlayerResizeHandle.tsx +50 -0
- package/studio/web/pages/VideoPage/components/SoundTimeline/PreviewControls.tsx +51 -0
- package/studio/web/pages/VideoPage/components/SoundTimeline/SceneRow.tsx +54 -0
- package/studio/web/pages/VideoPage/components/SoundTimeline/SettingsPanel.tsx +65 -0
- package/studio/web/pages/VideoPage/components/SoundTimeline/SoundLibrary.tsx +99 -0
- package/studio/web/pages/VideoPage/components/SoundTimeline/SoundPreview.tsx +82 -0
- package/studio/web/pages/VideoPage/components/SoundTimeline/SpeedPanel.tsx +128 -0
- package/studio/web/pages/VideoPage/components/SoundTimeline/TimelineDock.tsx +56 -0
- package/studio/web/pages/VideoPage/components/SoundTimeline/TimelineFooter.tsx +26 -0
- package/studio/web/pages/VideoPage/components/SoundTimeline/TimelineSkeleton.tsx +21 -0
- package/studio/web/pages/VideoPage/components/SoundTimeline/TimelineToolbar.tsx +160 -0
- package/studio/web/pages/VideoPage/components/SoundTimeline/TimelineTracks.tsx +121 -0
- package/studio/web/pages/VideoPage/components/SoundTimeline/TrackSidebar.tsx +30 -0
- package/studio/web/pages/VideoPage/components/SoundTimeline/VoiceRow.tsx +53 -0
- package/studio/web/pages/VideoPage/components/SoundTimeline/Waveform.tsx +97 -0
- package/studio/web/pages/VideoPage/components/SoundTimeline/clipActions.ts +50 -0
- package/studio/web/pages/VideoPage/components/SoundTimeline/effectActions.ts +161 -0
- package/studio/web/pages/VideoPage/components/SoundTimeline/index.tsx +161 -0
- package/studio/web/pages/VideoPage/components/SoundTimeline/timelineGeometry.ts +45 -0
- package/studio/web/pages/VideoPage/components/SoundTimeline/types.ts +43 -0
- package/studio/web/pages/VideoPage/components/SoundTimeline/useAutosave.ts +107 -0
- package/studio/web/pages/VideoPage/components/SoundTimeline/useEditorShortcuts.ts +168 -0
- package/studio/web/pages/VideoPage/components/SoundTimeline/useFitHeight.ts +40 -0
- package/studio/web/pages/VideoPage/components/SoundTimeline/useFollowPlayhead.ts +21 -0
- package/studio/web/pages/VideoPage/components/SoundTimeline/useListenMix.ts +43 -0
- package/studio/web/pages/VideoPage/components/SoundTimeline/usePlayback.ts +231 -0
- package/studio/web/pages/VideoPage/components/SoundTimeline/usePlayerWidth.ts +27 -0
- package/studio/web/pages/VideoPage/components/SoundTimeline/useSoundData.ts +124 -0
- package/studio/web/pages/VideoPage/components/SoundTimeline/useSoundTimeline.ts +511 -0
- package/studio/web/pages/VideoPage/components/StageDrawer/CompareVersions.tsx +111 -0
- package/studio/web/pages/VideoPage/components/StageDrawer/VersionCard.tsx +81 -0
- package/studio/web/pages/VideoPage/components/StageDrawer/VersionMedia.tsx +24 -0
- package/studio/web/pages/VideoPage/components/StageDrawer/VersionText.tsx +15 -0
- package/studio/web/pages/VideoPage/components/StageDrawer/index.tsx +109 -0
- package/studio/web/pages/VideoPage/components/StageProcess.tsx +126 -0
- package/studio/web/pages/VideoPage/components/StageResult.tsx +69 -0
- package/studio/web/pages/VideoPage/components/SwapReference.tsx +92 -0
- package/studio/web/pages/VideoPage/components/ThumbnailPanel.tsx +252 -0
- package/studio/web/pages/VideoPage/components/TimingPanel.tsx +89 -0
- package/studio/web/pages/VideoPage/components/VideoPlayer.tsx +18 -0
- package/studio/web/pages/VideoPage/components/VideoSkeleton.tsx +50 -0
- package/studio/web/pages/VideoPage/components/VideoVoiceInfo.tsx +33 -0
- package/studio/web/pages/VideoPage/components/VideoVoicePicker.tsx +165 -0
- package/studio/web/pages/VideoPage/components/VoicePauseBanner.tsx +56 -0
- package/studio/web/pages/VideoPage/components/WithLogo.tsx +10 -0
- package/studio/web/pages/VideoPage/index.tsx +295 -0
- package/studio/web/pages/VideosPage/components/PublishedOn.tsx +85 -0
- package/studio/web/pages/VideosPage/components/ServicesPanel.tsx +81 -0
- package/studio/web/pages/VideosPage/components/SessionsPanel.tsx +62 -0
- package/studio/web/pages/VideosPage/components/VideoListSkeleton.tsx +25 -0
- package/studio/web/pages/VideosPage/components/VideoRow.tsx +122 -0
- package/studio/web/pages/VideosPage/index.tsx +134 -0
- package/studio/web/pages/VoicePage/index.tsx +28 -0
- package/studio/web/router.tsx +201 -0
- package/studio/web/styles/base/elements.css +55 -0
- package/studio/web/styles/base/utilities.css +25 -0
- package/studio/web/styles/components/badge.css +33 -0
- package/studio/web/styles/components/claude-running.css +27 -0
- package/studio/web/styles/components/claude-session.css +212 -0
- package/studio/web/styles/components/claude-walking.css +92 -0
- package/studio/web/styles/components/clone-voice-save.css +136 -0
- package/studio/web/styles/components/clone-voice.css +379 -0
- package/studio/web/styles/components/controls.css +70 -0
- package/studio/web/styles/components/data-and-image.css +54 -0
- package/studio/web/styles/components/forms.css +132 -0
- package/studio/web/styles/components/human-banner.css +99 -0
- package/studio/web/styles/components/lightbox.css +50 -0
- package/studio/web/styles/components/markdown.css +68 -0
- package/studio/web/styles/components/modal.css +70 -0
- package/studio/web/styles/components/omni-cards.css +105 -0
- package/studio/web/styles/components/omni.css +859 -0
- package/studio/web/styles/components/omnichat.css +538 -0
- package/studio/web/styles/components/panel.css +115 -0
- package/studio/web/styles/components/platform-card.css +140 -0
- package/studio/web/styles/components/provider-card.css +132 -0
- package/studio/web/styles/components/queue.css +185 -0
- package/studio/web/styles/components/reference-actions.css +133 -0
- package/studio/web/styles/components/reference-picker.css +146 -0
- package/studio/web/styles/components/results-drawer.css +224 -0
- package/studio/web/styles/components/segmented.css +115 -0
- package/studio/web/styles/components/side-panel-tab.css +23 -0
- package/studio/web/styles/components/status-signals.css +66 -0
- package/studio/web/styles/components/stepper.css +208 -0
- package/studio/web/styles/components/table.css +19 -0
- package/studio/web/styles/components/tabs.css +24 -0
- package/studio/web/styles/components/ui/accordion.css +71 -0
- package/studio/web/styles/components/ui/chart.css +162 -0
- package/studio/web/styles/components/ui/checkbox.css +37 -0
- package/studio/web/styles/components/ui/date-picker.css +153 -0
- package/studio/web/styles/components/ui/select.css +110 -0
- package/studio/web/styles/components/ui/skeleton.css +25 -0
- package/studio/web/styles/components/ui/slider.css +81 -0
- package/studio/web/styles/components/ui/switch.css +41 -0
- package/studio/web/styles/components/ui/tooltip.css +24 -0
- package/studio/web/styles/components/voice-carousel.css +255 -0
- package/studio/web/styles/components/voice-editor.css +179 -0
- package/studio/web/styles/components/voice-grid.css +56 -0
- package/studio/web/styles/components/voice-profiles.css +192 -0
- package/studio/web/styles/design-system.md +136 -0
- package/studio/web/styles/index.css +116 -0
- package/studio/web/styles/layout/page.css +41 -0
- package/studio/web/styles/layout/sidebar-groups.css +39 -0
- package/studio/web/styles/layout/sidebar.css +111 -0
- package/studio/web/styles/pages/agent.css +26 -0
- package/studio/web/styles/pages/analytics/dashboard.css +614 -0
- package/studio/web/styles/pages/analytics/insights.css +48 -0
- package/studio/web/styles/pages/getting-started.css +166 -0
- package/studio/web/styles/pages/new-video/form.css +32 -0
- package/studio/web/styles/pages/not-found.css +59 -0
- package/studio/web/styles/pages/references.css +204 -0
- package/studio/web/styles/pages/schedule/calendar.css +541 -0
- package/studio/web/styles/pages/schedule/chip-states.css +60 -0
- package/studio/web/styles/pages/settings/models.css +72 -0
- package/studio/web/styles/pages/settings/shell.css +361 -0
- package/studio/web/styles/pages/settings/usage.css +63 -0
- package/studio/web/styles/pages/studio-offline.css +105 -0
- package/studio/web/styles/pages/video/caption-controls.css +194 -0
- package/studio/web/styles/pages/video/captions-grid.css +70 -0
- package/studio/web/styles/pages/video/captions.css +79 -0
- package/studio/web/styles/pages/video/editor-chat.css +103 -0
- package/studio/web/styles/pages/video/export.css +50 -0
- package/studio/web/styles/pages/video/frames.css +29 -0
- package/studio/web/styles/pages/video/fullscreen-stage.css +400 -0
- package/studio/web/styles/pages/video/instagram-carousel.css +309 -0
- package/studio/web/styles/pages/video/layout.css +285 -0
- package/studio/web/styles/pages/video/narration.css +138 -0
- package/studio/web/styles/pages/video/preview-overview.css +11 -0
- package/studio/web/styles/pages/video/processing-notice.css +34 -0
- package/studio/web/styles/pages/video/publish-when.css +76 -0
- package/studio/web/styles/pages/video/publish.css +97 -0
- package/studio/web/styles/pages/video/result.css +96 -0
- package/studio/web/styles/pages/video/scenes.css +167 -0
- package/studio/web/styles/pages/video/sound-timeline.css +672 -0
- package/studio/web/styles/pages/video/sound-toolbar.css +149 -0
- package/studio/web/styles/pages/video/take-player.css +100 -0
- package/studio/web/styles/pages/video/thumbnail.css +157 -0
- package/studio/web/styles/pages/video/timeline.css +117 -0
- package/studio/web/styles/pages/video/voice-picker.css +14 -0
- package/studio/web/styles/pages/video-list/active-row.css +27 -0
- package/studio/web/styles/pages/video-list/card-link.css +49 -0
- package/studio/web/styles/pages/video-list/list.css +86 -0
- package/studio/web/styles/theme/identity/details.css +204 -0
- package/studio/web/styles/theme/identity/shell.css +249 -0
- package/studio/web/styles/theme/identity/surfaces.css +183 -0
- package/studio/web/styles/theme/identity/titles-and-buttons.css +120 -0
- package/studio/web/styles/theme/no-borders/blocks.css +208 -0
- package/studio/web/styles/theme/no-borders/controls.css +256 -0
- package/studio/web/styles/tokens.css +245 -0
- package/studio/web/types/api.ts +2 -0
- package/studio/web/types/assets.d.ts +9 -0
- package/templates/brief.md +27 -0
- package/templates/captions.html +204 -0
- package/templates/carousel-composition.md +47 -0
- package/templates/carousel-script.md +60 -0
- package/templates/components/voiceavatar/README.md +87 -0
- package/templates/components/voiceavatar/demo.html +50 -0
- package/templates/components/voiceavatar/voiceavatar.js +415 -0
- package/templates/motion.md +80 -0
- package/templates/playbook.md +43 -0
- package/templates/reference.md +123 -0
- package/templates/scene.html +112 -0
- package/templates/scenes.example.json +96 -0
- package/templates/script.md +63 -0
- package/templates/slide.html +71 -0
- package/templates/wireframe.html +91 -0
- package/tools/analysis.mjs +300 -0
- package/tools/approval.mjs +442 -0
- package/tools/assemble.mjs +942 -0
- package/tools/build-motion.mjs +437 -0
- package/tools/captions.mjs +246 -0
- package/tools/carousel.mjs +558 -0
- package/tools/compose.mjs +307 -0
- package/tools/cuts.mjs +74 -0
- package/tools/editor.mjs +419 -0
- package/tools/epidemic-capture.js +54 -0
- package/tools/fanout.mjs +69 -0
- package/tools/history.mjs +70 -0
- package/tools/history.py +61 -0
- package/tools/job-queue.mjs +180 -0
- package/tools/job_queue.py +430 -0
- package/tools/lib/agents.mjs +412 -0
- package/tools/lib/channel.mjs +399 -0
- package/tools/lib/claude-settings.mjs +67 -0
- package/tools/lib/cli.mjs +9 -0
- package/tools/lib/fanout.mjs +234 -0
- package/tools/lib/http.mjs +94 -0
- package/tools/lib/index.js +14 -0
- package/tools/lib/mcp-server.mjs +69 -0
- package/tools/lib/metrics/metrics.mjs +204 -0
- package/tools/lib/narration.mjs +15 -0
- package/tools/lib/oauth-client.mjs +295 -0
- package/tools/lib/omnichat/config.mjs +139 -0
- package/tools/lib/omnichat/errors.mjs +42 -0
- package/tools/lib/omnichat/extract.mjs +82 -0
- package/tools/lib/omnichat/gate.mjs +151 -0
- package/tools/lib/omnichat/manual.mjs +166 -0
- package/tools/lib/omnichat/match.mjs +78 -0
- package/tools/lib/omnichat/pass.mjs +393 -0
- package/tools/lib/omnichat/record.mjs +208 -0
- package/tools/lib/omnichat/text.mjs +315 -0
- package/tools/lib/omnichat/webhook.mjs +166 -0
- package/tools/lib/pairing.mjs +99 -0
- package/tools/lib/paths.mjs +41 -0
- package/tools/lib/platforms/instagram.mjs +469 -0
- package/tools/lib/platforms/tiktok.mjs +343 -0
- package/tools/lib/platforms/youtube.mjs +228 -0
- package/tools/lib/publish/publish.mjs +244 -0
- package/tools/lib/schedule/schedule.mjs +191 -0
- package/tools/lib/service.mjs +134 -0
- package/tools/lib/skill-client.mjs +126 -0
- package/tools/lib/skills-release.mjs +196 -0
- package/tools/lib/testing.mjs +52 -0
- package/tools/lib/up.mjs +187 -0
- package/tools/metrics.mjs +415 -0
- package/tools/models.mjs +134 -0
- package/tools/narrate.mjs +257 -0
- package/tools/omni.mjs +298 -0
- package/tools/omni_paths.py +12 -0
- package/tools/omnichat.mjs +1577 -0
- package/tools/omnivideo.py +1122 -0
- package/tools/package.mjs +130 -0
- package/tools/platforms.mjs +236 -0
- package/tools/premiere.mjs +533 -0
- package/tools/publish.mjs +1496 -0
- package/tools/reference.mjs +1408 -0
- package/tools/render.mjs +426 -0
- package/tools/runner.mjs +156 -0
- package/tools/setup.mjs +466 -0
- package/tools/skills-release.mjs +93 -0
- package/tools/sound.mjs +1323 -0
- package/tools/soundtrack.mjs +217 -0
- package/tools/thumbnail.mjs +271 -0
- package/tools/timing.mjs +108 -0
- package/tools/usage.py +628 -0
- package/tools/voice-envelope.mjs +185 -0
- package/tools/voice.py +448 -0
- package/tools/voice_sample.py +75 -0
- 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, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
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
|
+
}
|