Sorry, the diff of this file is too big to display
| *,:before,:after,::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:#3b82f680;--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }*,:before,:after{box-sizing:border-box;border:0 solid #e5e7eb}:before,:after{--tw-content:""}html,:host{-webkit-text-size-adjust:100%;tab-size:4;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;line-height:1.5}body{line-height:inherit;margin:0}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-feature-settings:normal;font-variation-settings:normal;font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-feature-settings:inherit;font-variation-settings:inherit;font-family:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:#0000;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dl,dd,h1,h2,h3,h4,h5,h6,hr,figure,p,pre{margin:0}fieldset{margin:0;padding:0}legend{padding:0}ol,ul,menu{margin:0;padding:0;list-style:none}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder{opacity:1;color:#9ca3af}textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}button,[role=button]{cursor:pointer}:disabled{cursor:default}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}body{--tw-bg-opacity:1;background-color:rgb(248 250 252/var(--tw-bg-opacity,1));--tw-text-opacity:1;color:rgb(30 41 59/var(--tw-text-opacity,1));-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;font-family:Inter,system-ui,sans-serif}body:is(.dark *){--tw-bg-opacity:1;background-color:rgb(9 9 11/var(--tw-bg-opacity,1));--tw-text-opacity:1;color:rgb(244 244 245/var(--tw-text-opacity,1))}html.theme-ready body{transition:background-color .35s,color .35s}@media (prefers-reduced-motion:reduce){html.theme-ready body{transition:none}}.container{width:100%}@media (width>=640px){.container{max-width:640px}}@media (width>=768px){.container{max-width:768px}}@media (width>=1024px){.container{max-width:1024px}}@media (width>=1280px){.container{max-width:1280px}}@media (width>=1536px){.container{max-width:1536px}}.glass-panel{--tw-shadow:0 8px 32px #0000000d;--tw-shadow-colored:0 8px 32px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000), var(--tw-ring-shadow,0 0 #0000), var(--tw-shadow);--tw-backdrop-blur:blur(40px);-webkit-backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);background-color:#ffffffb3;border-width:1px;border-color:#ffffff80}.glass-panel:is(.dark *){background-color:#18181b99;border-color:#ffffff1a}.scrollbar-soft{scrollbar-width:thin;scrollbar-color:#a1a1aa80 transparent}.scrollbar-soft::-webkit-scrollbar{width:8px;height:8px}.scrollbar-soft::-webkit-scrollbar-track{background:0 0}.scrollbar-soft::-webkit-scrollbar-thumb{background-color:#a1a1aa73;background-clip:padding-box;border:2px solid #0000;border-radius:9999px}.scrollbar-soft::-webkit-scrollbar-thumb:hover{background-color:#71717ab3;background-clip:padding-box}.pp-seek{appearance:none;cursor:pointer;background:#ffffff38;border-radius:9999px;height:3px}.pp-seek::-webkit-slider-thumb{appearance:none;background:#fff;border-radius:9999px;width:9px;height:9px;transition:transform .12s;box-shadow:0 1px 3px #0006}.pp-seek:hover::-webkit-slider-thumb{transform:scale(1.4)}.pp-seek:focus-visible::-webkit-slider-thumb{transform:scale(1.4)}.pp-seek::-moz-range-thumb{background:#fff;border:none;border-radius:9999px;width:9px;height:9px;box-shadow:0 1px 3px #0006}.pp-seek:focus-visible{outline:none}@media (prefers-reduced-motion:reduce){.pp-seek::-webkit-slider-thumb{transition:none}}.sr-only{clip:rect(0, 0, 0, 0);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.pointer-events-none{pointer-events:none}.visible{visibility:visible}.invisible{visibility:hidden}.collapse{visibility:collapse}.static{position:static}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.sticky{position:sticky}.-inset-5{inset:-1.25rem}.inset-0{inset:0}.inset-x-0{left:0;right:0}.inset-y-0{top:0;bottom:0}.-bottom-24{bottom:-6rem}.-left-32{left:-8rem}.-right-1{right:-.25rem}.-right-24{right:-6rem}.-top-1{top:-.25rem}.-top-32{top:-8rem}.bottom-0{bottom:0}.bottom-1\.5{bottom:.375rem}.bottom-4{bottom:1rem}.left-0{left:0}.left-1\/3{left:33.3333%}.left-2\.5{left:.625rem}.right-0{right:0}.right-0\.5{right:.125rem}.right-1{right:.25rem}.right-1\.5{right:.375rem}.right-2{right:.5rem}.right-2\.5{right:.625rem}.right-3{right:.75rem}.right-4{right:1rem}.top-0{top:0}.top-0\.5{top:.125rem}.top-1{top:.25rem}.top-1\.5{top:.375rem}.top-1\/2{top:50%}.top-1\/3{top:33.3333%}.top-2{top:.5rem}.z-0{z-index:0}.z-10{z-index:10}.z-40{z-index:40}.z-50{z-index:50}.z-\[60\]{z-index:60}.z-\[70\]{z-index:70}.z-\[80\]{z-index:80}.col-span-3{grid-column:span 3/span 3}.col-span-7{grid-column:span 7/span 7}.m-0{margin:0}.-mx-2{margin-left:-.5rem;margin-right:-.5rem}.mx-0\.5{margin-left:.125rem;margin-right:.125rem}.mx-1{margin-left:.25rem;margin-right:.25rem}.mx-auto{margin-left:auto;margin-right:auto}.my-1{margin-top:.25rem;margin-bottom:.25rem}.-mt-0\.5{margin-top:-.125rem}.-mt-1{margin-top:-.25rem}.-mt-2{margin-top:-.5rem}.mb-1{margin-bottom:.25rem}.mb-1\.5{margin-bottom:.375rem}.mb-2{margin-bottom:.5rem}.mb-3{margin-bottom:.75rem}.mb-4{margin-bottom:1rem}.ml-1{margin-left:.25rem}.ml-1\.5{margin-left:.375rem}.ml-4{margin-left:1rem}.ml-auto{margin-left:auto}.mr-1{margin-right:.25rem}.mr-1\.5{margin-right:.375rem}.mr-auto{margin-right:auto}.mt-0\.5{margin-top:.125rem}.mt-1{margin-top:.25rem}.mt-1\.5{margin-top:.375rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.mt-auto{margin-top:auto}.mt-px{margin-top:1px}.line-clamp-2{-webkit-line-clamp:2;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}.line-clamp-3{-webkit-line-clamp:3;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}.block{display:block}.inline{display:inline}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.contents{display:contents}.hidden{display:none}.aspect-\[1\.91\/1\]{aspect-ratio:1.91}.aspect-\[4\/5\]{aspect-ratio:4/5}.aspect-\[9\/16\]{aspect-ratio:9/16}.aspect-square{aspect-ratio:1}.aspect-video{aspect-ratio:16/9}.h-1{height:.25rem}.h-1\.5{height:.375rem}.h-10{height:2.5rem}.h-12{height:3rem}.h-14{height:3.5rem}.h-16{height:4rem}.h-2{height:.5rem}.h-24{height:6rem}.h-28{height:7rem}.h-3{height:.75rem}.h-4{height:1rem}.h-5{height:1.25rem}.h-6{height:1.5rem}.h-7{height:1.75rem}.h-72{height:18rem}.h-8{height:2rem}.h-80{height:20rem}.h-9{height:2.25rem}.h-96{height:24rem}.h-\[252px\]{height:252px}.h-\[72px\]{height:72px}.h-full{height:100%}.h-px{height:1px}.max-h-72{max-height:18rem}.max-h-\[70vh\]{max-height:70vh}.max-h-\[85vh\]{max-height:85vh}.max-h-\[92vh\]{max-height:92vh}.min-h-0{min-height:0}.min-h-48{min-height:12rem}.min-h-\[92px\]{min-height:92px}.min-h-dvh{min-height:100dvh}.w-1{width:.25rem}.w-10{width:2.5rem}.w-11{width:2.75rem}.w-12{width:3rem}.w-16{width:4rem}.w-2{width:.5rem}.w-24{width:6rem}.w-28{width:7rem}.w-4{width:1rem}.w-44{width:11rem}.w-56{width:14rem}.w-6{width:1.5rem}.w-60{width:15rem}.w-64{width:16rem}.w-7{width:1.75rem}.w-72{width:18rem}.w-8{width:2rem}.w-80{width:20rem}.w-9{width:2.25rem}.w-96{width:24rem}.w-\[420px\]{width:420px}.w-\[440px\]{width:440px}.w-\[72px\]{width:72px}.w-auto{width:auto}.w-fit{width:fit-content}.w-full{width:100%}.w-px{width:1px}.min-w-0{min-width:0}.min-w-\[1\.25rem\]{min-width:1.25rem}.min-w-\[840px\]{min-width:840px}.max-w-2xl{max-width:42rem}.max-w-3xl{max-width:48rem}.max-w-4xl{max-width:56rem}.max-w-5xl{max-width:64rem}.max-w-\[10rem\]{max-width:10rem}.max-w-\[14ch\]{max-width:14ch}.max-w-\[16rem\]{max-width:16rem}.max-w-\[4rem\]{max-width:4rem}.max-w-\[80\%\]{max-width:80%}.max-w-\[90vw\]{max-width:90vw}.max-w-\[94vw\]{max-width:94vw}.max-w-full{max-width:100%}.max-w-lg{max-width:32rem}.max-w-none{max-width:none}.max-w-sm{max-width:24rem}.max-w-xl{max-width:36rem}.max-w-xs{max-width:20rem}.flex-1{flex:1}.shrink-0{flex-shrink:0}.basis-full{flex-basis:100%}.-translate-x-1\/2{--tw-translate-x:-50%;transform:translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.-translate-y-1\/2{--tw-translate-y:-50%;transform:translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.translate-x-0\.5{--tw-translate-x:.125rem;transform:translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.translate-x-4{--tw-translate-x:1rem;transform:translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.rotate-180{--tw-rotate:180deg;transform:translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.transform{transform:translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@keyframes blob{0%,to{transform:translate(0)scale(1)}33%{transform:translate(50px,-70px)scale(1.08)}66%{transform:translate(-40px,40px)scale(.94)}}.animate-blob{animation:20s ease-in-out infinite blob}.animate-blob-slow{animation:28s ease-in-out infinite reverse blob}@keyframes ping{75%,to{opacity:0;transform:scale(2)}}.animate-ping{animation:1s cubic-bezier(0,0,.2,1) infinite ping}@keyframes pulse{50%{opacity:.5}}.animate-pulse{animation:2s cubic-bezier(.4,0,.6,1) infinite pulse}@keyframes slide-in{0%{opacity:0;transform:translate(48px)}to{opacity:1;transform:translate(0)}}.animate-slide-in{animation:.26s cubic-bezier(.32,.72,0,1) both slide-in}@keyframes spin{to{transform:rotate(360deg)}}.animate-spin{animation:1s linear infinite spin}.cursor-default{cursor:default}.cursor-none{cursor:none}.cursor-not-allowed{cursor:not-allowed}.cursor-pointer{cursor:pointer}.resize-y{resize:vertical}.list-decimal{list-style-type:decimal}.list-none{list-style-type:none}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.grid-cols-7{grid-template-columns:repeat(7,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.place-items-center{place-items:center}.content-start{align-content:flex-start}.items-start{align-items:flex-start}.items-end{align-items:flex-end}.items-center{align-items:center}.items-baseline{align-items:baseline}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-0\.5{gap:.125rem}.gap-1{gap:.25rem}.gap-1\.5{gap:.375rem}.gap-2{gap:.5rem}.gap-2\.5{gap:.625rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-5{gap:1.25rem}.gap-6{gap:1.5rem}.gap-x-1\.5{-moz-column-gap:.375rem;column-gap:.375rem}.gap-x-2{-moz-column-gap:.5rem;column-gap:.5rem}.gap-x-2\.5{-moz-column-gap:.625rem;column-gap:.625rem}.gap-x-3{-moz-column-gap:.75rem;column-gap:.75rem}.gap-x-4{-moz-column-gap:1rem;column-gap:1rem}.gap-y-0\.5{row-gap:.125rem}.gap-y-1{row-gap:.25rem}.gap-y-1\.5{row-gap:.375rem}.gap-y-3{row-gap:.75rem}.space-y-0\.5>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.125rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.125rem * var(--tw-space-y-reverse))}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.25rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.25rem * var(--tw-space-y-reverse))}.space-y-1\.5>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.375rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.375rem * var(--tw-space-y-reverse))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.5rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.5rem * var(--tw-space-y-reverse))}.space-y-2\.5>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.625rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.625rem * var(--tw-space-y-reverse))}.space-y-3>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.75rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.75rem * var(--tw-space-y-reverse))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1rem * var(--tw-space-y-reverse))}.space-y-5>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1.25rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.25rem * var(--tw-space-y-reverse))}.space-y-6>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1.5rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.5rem * var(--tw-space-y-reverse))}.divide-y>:not([hidden])~:not([hidden]){--tw-divide-y-reverse:0;border-top-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)));border-bottom-width:calc(1px * var(--tw-divide-y-reverse))}.divide-black\/5>:not([hidden])~:not([hidden]){border-color:#0000000d}.self-center{align-self:center}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.whitespace-nowrap{white-space:nowrap}.whitespace-pre-wrap{white-space:pre-wrap}.break-words{overflow-wrap:break-word}.break-all{word-break:break-all}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:1rem}.rounded-\[10px\]{border-radius:10px}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:.5rem}.rounded-md{border-radius:.375rem}.rounded-xl{border-radius:.75rem}.rounded-l-2xl{border-top-left-radius:1rem;border-bottom-left-radius:1rem}.border{border-width:1px}.border-0{border-width:0}.border-2{border-width:2px}.border-b{border-bottom-width:1px}.border-l{border-left-width:1px}.border-t{border-top-width:1px}.border-dashed{border-style:dashed}.border-amber-500\/30{border-color:#f59e0b4d}.border-black\/10{border-color:#0000001a}.border-black\/5{border-color:#0000000d}.border-brand{border-color:var(--accent,#0f766e)}.border-zinc-200\/50{border-color:#e4e4e780}.border-zinc-200\/60{border-color:#e4e4e799}.border-zinc-200\/70{border-color:#e4e4e7b3}.border-zinc-300{--tw-border-opacity:1;border-color:rgb(212 212 216/var(--tw-border-opacity,1))}.border-zinc-300\/60{border-color:#d4d4d899}.border-zinc-300\/70{border-color:#d4d4d8b3}.border-zinc-900\/5{border-color:#18181b0d}.bg-amber-500{--tw-bg-opacity:1;background-color:rgb(245 158 11/var(--tw-bg-opacity,1))}.bg-amber-500\/10{background-color:#f59e0b1a}.bg-amber-500\/15{background-color:#f59e0b26}.bg-amber-500\/20{background-color:#f59e0b33}.bg-amber-500\/90{background-color:#f59e0be6}.bg-black{--tw-bg-opacity:1;background-color:rgb(0 0 0/var(--tw-bg-opacity,1))}.bg-black\/40{background-color:#0006}.bg-black\/45{background-color:#00000073}.bg-black\/5{background-color:#0000000d}.bg-black\/55{background-color:#0000008c}.bg-black\/80{background-color:#000c}.bg-black\/90{background-color:#000000e6}.bg-blue-400\/15{background-color:#60a5fa26}.bg-brand{background-color:var(--accent,#0f766e)}.bg-current{background-color:currentColor}.bg-cyan-400\/20{background-color:#22d3ee33}.bg-cyan-500{--tw-bg-opacity:1;background-color:rgb(6 182 212/var(--tw-bg-opacity,1))}.bg-cyan-500\/15{background-color:#06b6d426}.bg-emerald-500{--tw-bg-opacity:1;background-color:rgb(16 185 129/var(--tw-bg-opacity,1))}.bg-emerald-500\/10{background-color:#10b9811a}.bg-emerald-500\/15{background-color:#10b98126}.bg-emerald-600{--tw-bg-opacity:1;background-color:rgb(5 150 105/var(--tw-bg-opacity,1))}.bg-emerald-600\/15{background-color:#05966926}.bg-orange-500{--tw-bg-opacity:1;background-color:rgb(249 115 22/var(--tw-bg-opacity,1))}.bg-orange-500\/10{background-color:#f973161a}.bg-orange-500\/15{background-color:#f9731626}.bg-red-500{--tw-bg-opacity:1;background-color:rgb(239 68 68/var(--tw-bg-opacity,1))}.bg-red-500\/10{background-color:#ef44441a}.bg-red-500\/15{background-color:#ef444426}.bg-red-500\/70{background-color:#ef4444b3}.bg-red-600{--tw-bg-opacity:1;background-color:rgb(220 38 38/var(--tw-bg-opacity,1))}.bg-red-600\/90{background-color:#dc2626e6}.bg-sky-500{--tw-bg-opacity:1;background-color:rgb(14 165 233/var(--tw-bg-opacity,1))}.bg-sky-500\/15{background-color:#0ea5e926}.bg-slate-400{--tw-bg-opacity:1;background-color:rgb(148 163 184/var(--tw-bg-opacity,1))}.bg-slate-500\/10{background-color:#64748b1a}.bg-slate-500\/15{background-color:#64748b26}.bg-teal-400\/15{background-color:#2dd4bf26}.bg-teal-500{--tw-bg-opacity:1;background-color:rgb(20 184 166/var(--tw-bg-opacity,1))}.bg-teal-500\/15{background-color:#14b8a626}.bg-transparent{background-color:#0000}.bg-white{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity,1))}.bg-white\/20{background-color:#fff3}.bg-white\/25{background-color:#ffffff40}.bg-white\/40{background-color:#fff6}.bg-white\/45{background-color:#ffffff73}.bg-white\/50{background-color:#ffffff80}.bg-white\/60{background-color:#fff9}.bg-white\/70{background-color:#ffffffb3}.bg-white\/80{background-color:#fffc}.bg-white\/95{background-color:#fffffff2}.bg-zinc-100\/70{background-color:#f4f4f5b3}.bg-zinc-200{--tw-bg-opacity:1;background-color:rgb(228 228 231/var(--tw-bg-opacity,1))}.bg-zinc-200\/40{background-color:#e4e4e766}.bg-zinc-200\/50{background-color:#e4e4e780}.bg-zinc-200\/60{background-color:#e4e4e799}.bg-zinc-200\/70{background-color:#e4e4e7b3}.bg-zinc-200\/80{background-color:#e4e4e7cc}.bg-zinc-300{--tw-bg-opacity:1;background-color:rgb(212 212 216/var(--tw-bg-opacity,1))}.bg-zinc-300\/40{background-color:#d4d4d866}.bg-zinc-300\/70{background-color:#d4d4d8b3}.bg-zinc-400{--tw-bg-opacity:1;background-color:rgb(161 161 170/var(--tw-bg-opacity,1))}.bg-zinc-500\/10{background-color:#71717a1a}.bg-zinc-500\/15{background-color:#71717a26}.bg-zinc-900{--tw-bg-opacity:1;background-color:rgb(24 24 27/var(--tw-bg-opacity,1))}.bg-zinc-900\/5{background-color:#18181b0d}.bg-zinc-900\/60{background-color:#18181b99}.bg-zinc-900\/\[0\.06\]{background-color:#18181b0f}.bg-gradient-to-t{background-image:linear-gradient(to top, var(--tw-gradient-stops))}.from-black\/55{--tw-gradient-from:#0000008c var(--tw-gradient-from-position);--tw-gradient-to:#0000 var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from), var(--tw-gradient-to)}.via-black\/25{--tw-gradient-to:#0000 var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from), #00000040 var(--tw-gradient-via-position), var(--tw-gradient-to)}.to-transparent{--tw-gradient-to:transparent var(--tw-gradient-to-position)}.fill-zinc-900{fill:#18181b}.object-contain{-o-object-fit:contain;object-fit:contain}.object-cover{-o-object-fit:cover;object-fit:cover}.object-top{-o-object-position:top;object-position:top}.p-0\.5{padding:.125rem}.p-1{padding:.25rem}.p-1\.5{padding:.375rem}.p-2{padding:.5rem}.p-2\.5{padding:.625rem}.p-3{padding:.75rem}.p-4{padding:1rem}.p-5{padding:1.25rem}.p-8{padding:2rem}.px-1{padding-left:.25rem;padding-right:.25rem}.px-1\.5{padding-left:.375rem;padding-right:.375rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-2\.5{padding-left:.625rem;padding-right:.625rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-3\.5{padding-left:.875rem;padding-right:.875rem}.px-4{padding-left:1rem;padding-right:1rem}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-1\.5{padding-top:.375rem;padding-bottom:.375rem}.py-12{padding-top:3rem;padding-bottom:3rem}.py-16{padding-top:4rem;padding-bottom:4rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-2\.5{padding-top:.625rem;padding-bottom:.625rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.py-6{padding-top:1.5rem;padding-bottom:1.5rem}.py-8{padding-top:2rem;padding-bottom:2rem}.pb-1{padding-bottom:.25rem}.pb-1\.5{padding-bottom:.375rem}.pb-2{padding-bottom:.5rem}.pl-0\.5{padding-left:.125rem}.pl-2{padding-left:.5rem}.pl-3{padding-left:.75rem}.pl-8{padding-left:2rem}.pl-\[25px\]{padding-left:25px}.pr-0\.5{padding-right:.125rem}.pr-1{padding-right:.25rem}.pr-2{padding-right:.5rem}.pr-3{padding-right:.75rem}.pr-6{padding-right:1.5rem}.pr-9{padding-right:2.25rem}.pt-0\.5{padding-top:.125rem}.pt-1{padding-top:.25rem}.pt-1\.5{padding-top:.375rem}.pt-2{padding-top:.5rem}.pt-2\.5{padding-top:.625rem}.pt-3{padding-top:.75rem}.pt-4{padding-top:1rem}.pt-6{padding-top:1.5rem}.pt-\[12vh\]{padding-top:12vh}.text-left{text-align:left}.text-center{text-align:center}.text-right{text-align:right}.align-middle{vertical-align:middle}.font-body,.font-display{font-family:Inter,system-ui,sans-serif}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.text-2xl{font-size:1.5rem;line-height:2rem}.text-\[10px\]{font-size:10px}.text-\[11px\]{font-size:11px}.text-\[13px\]{font-size:13px}.text-\[9px\]{font-size:9px}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xs{font-size:.75rem;line-height:1rem}.font-bold{font-weight:700}.font-medium{font-weight:500}.font-normal{font-weight:400}.uppercase{text-transform:uppercase}.lowercase{text-transform:lowercase}.tabular-nums{--tw-numeric-spacing:tabular-nums;font-variant-numeric:var(--tw-ordinal) var(--tw-slashed-zero) var(--tw-numeric-figure) var(--tw-numeric-spacing) var(--tw-numeric-fraction)}.leading-none{line-height:1}.leading-relaxed{line-height:1.625}.leading-snug{line-height:1.375}.leading-tight{line-height:1.25}.tracking-tight{letter-spacing:-.025em}.tracking-wide{letter-spacing:.025em}.text-\[\#0A66C2\]{--tw-text-opacity:1;color:rgb(10 102 194/var(--tw-text-opacity,1))}.text-\[\#1877F2\]{--tw-text-opacity:1;color:rgb(24 119 242/var(--tw-text-opacity,1))}.text-\[\#229ED9\]{--tw-text-opacity:1;color:rgb(34 158 217/var(--tw-text-opacity,1))}.text-\[\#5865F2\]{--tw-text-opacity:1;color:rgb(88 101 242/var(--tw-text-opacity,1))}.text-\[\#E4405F\]{--tw-text-opacity:1;color:rgb(228 64 95/var(--tw-text-opacity,1))}.text-\[\#E60023\]{--tw-text-opacity:1;color:rgb(230 0 35/var(--tw-text-opacity,1))}.text-\[\#FF0000\]{--tw-text-opacity:1;color:rgb(255 0 0/var(--tw-text-opacity,1))}.text-\[\#FF4500\]{--tw-text-opacity:1;color:rgb(255 69 0/var(--tw-text-opacity,1))}.text-amber-500{--tw-text-opacity:1;color:rgb(245 158 11/var(--tw-text-opacity,1))}.text-amber-600{--tw-text-opacity:1;color:rgb(217 119 6/var(--tw-text-opacity,1))}.text-amber-600\/90{color:#d97706e6}.text-amber-700{--tw-text-opacity:1;color:rgb(180 83 9/var(--tw-text-opacity,1))}.text-amber-700\/70{color:#b45309b3}.text-amber-700\/80{color:#b45309cc}.text-amber-800{--tw-text-opacity:1;color:rgb(146 64 14/var(--tw-text-opacity,1))}.text-brand{color:var(--accent,#0f766e)}.text-cyan-700{--tw-text-opacity:1;color:rgb(14 116 144/var(--tw-text-opacity,1))}.text-emerald-500{--tw-text-opacity:1;color:rgb(16 185 129/var(--tw-text-opacity,1))}.text-emerald-600{--tw-text-opacity:1;color:rgb(5 150 105/var(--tw-text-opacity,1))}.text-emerald-700{--tw-text-opacity:1;color:rgb(4 120 87/var(--tw-text-opacity,1))}.text-emerald-700\/70{color:#047857b3}.text-emerald-700\/80{color:#047857cc}.text-emerald-800{--tw-text-opacity:1;color:rgb(6 95 70/var(--tw-text-opacity,1))}.text-orange-700{--tw-text-opacity:1;color:rgb(194 65 12/var(--tw-text-opacity,1))}.text-red-500{--tw-text-opacity:1;color:rgb(239 68 68/var(--tw-text-opacity,1))}.text-red-600{--tw-text-opacity:1;color:rgb(220 38 38/var(--tw-text-opacity,1))}.text-red-600\/90{color:#dc2626e6}.text-red-700{--tw-text-opacity:1;color:rgb(185 28 28/var(--tw-text-opacity,1))}.text-red-700\/80{color:#b91c1ccc}.text-sky-700{--tw-text-opacity:1;color:rgb(3 105 161/var(--tw-text-opacity,1))}.text-slate-600{--tw-text-opacity:1;color:rgb(71 85 105/var(--tw-text-opacity,1))}.text-teal-700{--tw-text-opacity:1;color:rgb(15 118 110/var(--tw-text-opacity,1))}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.text-white\/75{color:#ffffffbf}.text-white\/85{color:#ffffffd9}.text-white\/90{color:#ffffffe6}.text-zinc-300{--tw-text-opacity:1;color:rgb(212 212 216/var(--tw-text-opacity,1))}.text-zinc-400{--tw-text-opacity:1;color:rgb(161 161 170/var(--tw-text-opacity,1))}.text-zinc-50{--tw-text-opacity:1;color:rgb(250 250 250/var(--tw-text-opacity,1))}.text-zinc-500{--tw-text-opacity:1;color:rgb(113 113 122/var(--tw-text-opacity,1))}.text-zinc-600{--tw-text-opacity:1;color:rgb(82 82 91/var(--tw-text-opacity,1))}.text-zinc-700{--tw-text-opacity:1;color:rgb(63 63 70/var(--tw-text-opacity,1))}.text-zinc-800{--tw-text-opacity:1;color:rgb(39 39 42/var(--tw-text-opacity,1))}.text-zinc-900{--tw-text-opacity:1;color:rgb(24 24 27/var(--tw-text-opacity,1))}.text-zinc-950{--tw-text-opacity:1;color:rgb(9 9 11/var(--tw-text-opacity,1))}.underline{text-decoration-line:underline}.decoration-zinc-400{text-decoration-color:#a1a1aa}.decoration-dotted{text-decoration-style:dotted}.underline-offset-2{text-underline-offset:2px}.accent-brand{accent-color:var(--accent,#0f766e)}.accent-emerald-600{accent-color:#059669}.opacity-0{opacity:0}.opacity-100{opacity:1}.opacity-40{opacity:.4}.opacity-50{opacity:.5}.opacity-60{opacity:.6}.opacity-80{opacity:.8}.opacity-\[0\.03\]{opacity:.03}.mix-blend-overlay{mix-blend-mode:overlay}.shadow{--tw-shadow:0 1px 3px 0 #0000001a, 0 1px 2px -1px #0000001a;--tw-shadow-colored:0 1px 3px 0 var(--tw-shadow-color), 0 1px 2px -1px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000), var(--tw-ring-shadow,0 0 #0000), var(--tw-shadow)}.shadow-2xl{--tw-shadow:0 25px 50px -12px #00000040;--tw-shadow-colored:0 25px 50px -12px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000), var(--tw-ring-shadow,0 0 #0000), var(--tw-shadow)}.shadow-\[0_8px_32px_rgba\(0\,0\,0\,0\.05\)\]{--tw-shadow:0 8px 32px #0000000d;--tw-shadow-colored:0 8px 32px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000), var(--tw-ring-shadow,0 0 #0000), var(--tw-shadow)}.shadow-lg{--tw-shadow:0 10px 15px -3px #0000001a, 0 4px 6px -4px #0000001a;--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color), 0 4px 6px -4px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000), var(--tw-ring-shadow,0 0 #0000), var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 2px 0 #0000000d;--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000), var(--tw-ring-shadow,0 0 #0000), var(--tw-shadow)}.shadow-xl{--tw-shadow:0 20px 25px -5px #0000001a, 0 8px 10px -6px #0000001a;--tw-shadow-colored:0 20px 25px -5px var(--tw-shadow-color), 0 8px 10px -6px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000), var(--tw-ring-shadow,0 0 #0000), var(--tw-shadow)}.outline-none{outline-offset:2px;outline:2px solid #0000}.outline{outline-style:solid}.ring{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow,0 0 #0000)}.ring-1{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow,0 0 #0000)}.ring-2{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow,0 0 #0000)}.ring-amber-300\/40{--tw-ring-color:#fcd34d66}.ring-amber-500\/30{--tw-ring-color:#f59e0b4d}.ring-amber-500\/40{--tw-ring-color:#f59e0b66}.ring-black\/10{--tw-ring-color:#0000001a}.ring-brand{--tw-ring-color:var(--accent,#0f766e)}.ring-cyan-500\/30{--tw-ring-color:#06b6d44d}.ring-emerald-500\/30{--tw-ring-color:#10b9814d}.ring-emerald-600\/40{--tw-ring-color:#05966966}.ring-orange-500\/30{--tw-ring-color:#f973164d}.ring-red-300\/40{--tw-ring-color:#fca5a566}.ring-red-500\/30{--tw-ring-color:#ef44444d}.ring-red-500\/40{--tw-ring-color:#ef444466}.ring-red-500\/60{--tw-ring-color:#ef444499}.ring-sky-500\/30{--tw-ring-color:#0ea5e94d}.ring-slate-500\/30{--tw-ring-color:#64748b4d}.ring-teal-500\/30{--tw-ring-color:#14b8a64d}.ring-transparent{--tw-ring-color:transparent}.ring-white{--tw-ring-opacity:1;--tw-ring-color:rgb(255 255 255/var(--tw-ring-opacity,1))}.ring-white\/20{--tw-ring-color:#fff3}.ring-zinc-300{--tw-ring-opacity:1;--tw-ring-color:rgb(212 212 216/var(--tw-ring-opacity,1))}.ring-zinc-500\/20{--tw-ring-color:#71717a33}.ring-zinc-500\/30{--tw-ring-color:#71717a4d}.ring-zinc-900\/10{--tw-ring-color:#18181b1a}.ring-zinc-900\/5{--tw-ring-color:#18181b0d}.ring-zinc-900\/\[0\.06\]{--tw-ring-color:#18181b0f}.blur{--tw-blur:blur(8px);filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.blur-3xl{--tw-blur:blur(64px);filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.drop-shadow{--tw-drop-shadow:drop-shadow(0 1px 2px #0000001a) drop-shadow(0 1px 1px #0000000f);filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.backdrop-blur{--tw-backdrop-blur:blur(8px);-webkit-backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}.backdrop-blur-sm{--tw-backdrop-blur:blur(4px);-webkit-backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}.backdrop-blur-xl{--tw-backdrop-blur:blur(24px);-webkit-backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}.transition{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter,backdrop-filter;transition-duration:.15s;transition-timing-function:cubic-bezier(.4,0,.2,1)}.transition-opacity{transition-property:opacity;transition-duration:.15s;transition-timing-function:cubic-bezier(.4,0,.2,1)}.transition-transform{transition-property:transform;transition-duration:.15s;transition-timing-function:cubic-bezier(.4,0,.2,1)}.duration-200{transition-duration:.2s}.ease-in-out{transition-timing-function:cubic-bezier(.4,0,.2,1)}@font-face{font-family:Inter;font-style:normal;font-weight:100 900;font-display:swap;src:url(/assets/inter-latin-variable-Dx4kXJAl.woff2)format("woff2")}@keyframes pp-breathe{0%,to{transform:scale(1)}50%{transform:scale(1.055)}}@keyframes pp-chevron-clear{0%{stroke-dashoffset:64px}55%{stroke-dashoffset:0}80%{stroke-dashoffset:0}to{stroke-dashoffset:-64px}}@keyframes pp-rest{0%,to{transform:translateY(0)}50%{transform:translateY(-5px)}}@keyframes pp-glow-pulse{0%,to{opacity:.5}50%{opacity:.9}}@media (prefers-reduced-motion:reduce){[class~=pp-anim],[style*=pp-breathe],[style*=pp-chevron-clear],[style*=pp-rest],[style*=pp-glow-pulse]{animation:none!important}}.file\:mr-3::file-selector-button{margin-right:.75rem}.file\:rounded-lg::file-selector-button{border-radius:.5rem}.file\:border-0::file-selector-button{border-width:0}.file\:bg-brand::file-selector-button{background-color:var(--accent,#0f766e)}.file\:px-3::file-selector-button{padding-left:.75rem;padding-right:.75rem}.file\:py-1\.5::file-selector-button{padding-top:.375rem;padding-bottom:.375rem}.file\:text-xs::file-selector-button{font-size:.75rem;line-height:1rem}.file\:font-bold::file-selector-button{font-weight:700}.file\:text-white::file-selector-button{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.placeholder\:font-normal::placeholder{font-weight:400}.placeholder\:text-zinc-400::placeholder{--tw-text-opacity:1;color:rgb(161 161 170/var(--tw-text-opacity,1))}.last\:border-0:last-child{border-width:0}.focus-within\:outline-none:focus-within{outline-offset:2px;outline:2px solid #0000}.focus-within\:ring-2:focus-within{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow,0 0 #0000)}.focus-within\:ring-brand:focus-within{--tw-ring-color:var(--accent,#0f766e)}.hover\:-translate-y-1:hover{--tw-translate-y:-.25rem;transform:translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.hover\:border-black\/20:hover{border-color:#0003}.hover\:bg-amber-500\/20:hover{background-color:#f59e0b33}.hover\:bg-amber-500\/30:hover{background-color:#f59e0b4d}.hover\:bg-black\/70:hover{background-color:#000000b3}.hover\:bg-emerald-500\/20:hover{background-color:#10b98133}.hover\:bg-emerald-500\/25:hover{background-color:#10b98140}.hover\:bg-red-500\/10:hover{background-color:#ef44441a}.hover\:bg-red-500\/15:hover{background-color:#ef444426}.hover\:bg-red-500\/20:hover{background-color:#ef444433}.hover\:bg-red-700:hover{--tw-bg-opacity:1;background-color:rgb(185 28 28/var(--tw-bg-opacity,1))}.hover\:bg-white:hover{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity,1))}.hover\:bg-white\/15:hover{background-color:#ffffff26}.hover\:bg-white\/70:hover{background-color:#ffffffb3}.hover\:bg-white\/80:hover{background-color:#fffc}.hover\:bg-white\/90:hover{background-color:#ffffffe6}.hover\:bg-zinc-100:hover{--tw-bg-opacity:1;background-color:rgb(244 244 245/var(--tw-bg-opacity,1))}.hover\:bg-zinc-200:hover{--tw-bg-opacity:1;background-color:rgb(228 228 231/var(--tw-bg-opacity,1))}.hover\:bg-zinc-200\/40:hover{background-color:#e4e4e766}.hover\:bg-zinc-200\/50:hover{background-color:#e4e4e780}.hover\:bg-zinc-200\/60:hover{background-color:#e4e4e799}.hover\:bg-zinc-300\/50:hover{background-color:#d4d4d880}.hover\:bg-zinc-300\/60:hover{background-color:#d4d4d899}.hover\:text-amber-900:hover{--tw-text-opacity:1;color:rgb(120 53 15/var(--tw-text-opacity,1))}.hover\:text-emerald-900:hover{--tw-text-opacity:1;color:rgb(6 78 59/var(--tw-text-opacity,1))}.hover\:text-red-600:hover{--tw-text-opacity:1;color:rgb(220 38 38/var(--tw-text-opacity,1))}.hover\:text-white:hover{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.hover\:text-zinc-600:hover{--tw-text-opacity:1;color:rgb(82 82 91/var(--tw-text-opacity,1))}.hover\:text-zinc-700:hover{--tw-text-opacity:1;color:rgb(63 63 70/var(--tw-text-opacity,1))}.hover\:text-zinc-900:hover{--tw-text-opacity:1;color:rgb(24 24 27/var(--tw-text-opacity,1))}.hover\:underline:hover{text-decoration-line:underline}.hover\:decoration-zinc-700:hover{text-decoration-color:#3f3f46}.hover\:opacity-100:hover{opacity:1}.hover\:opacity-90:hover{opacity:.9}.hover\:shadow-xl:hover{--tw-shadow:0 20px 25px -5px #0000001a, 0 8px 10px -6px #0000001a;--tw-shadow-colored:0 20px 25px -5px var(--tw-shadow-color), 0 8px 10px -6px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000), var(--tw-ring-shadow,0 0 #0000), var(--tw-shadow)}.hover\:ring-1:hover{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow,0 0 #0000)}.focus\:outline-none:focus,.focus-visible\:outline-none:focus-visible{outline-offset:2px;outline:2px solid #0000}.focus-visible\:ring-2:focus-visible{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow,0 0 #0000)}.focus-visible\:ring-amber-500:focus-visible{--tw-ring-opacity:1;--tw-ring-color:rgb(245 158 11/var(--tw-ring-opacity,1))}.focus-visible\:ring-brand:focus-visible{--tw-ring-color:var(--accent,#0f766e)}.focus-visible\:ring-emerald-500:focus-visible{--tw-ring-opacity:1;--tw-ring-color:rgb(16 185 129/var(--tw-ring-opacity,1))}.focus-visible\:ring-red-500:focus-visible{--tw-ring-opacity:1;--tw-ring-color:rgb(239 68 68/var(--tw-ring-opacity,1))}.focus-visible\:ring-white\/60:focus-visible{--tw-ring-color:#fff9}.focus-visible\:ring-white\/70:focus-visible{--tw-ring-color:#ffffffb3}.focus-visible\:ring-offset-1:focus-visible{--tw-ring-offset-width:1px}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:opacity-40:disabled{opacity:.4}.disabled\:opacity-50:disabled{opacity:.5}.disabled\:opacity-60:disabled{opacity:.6}.disabled\:hover\:bg-transparent:hover:disabled{background-color:#0000}.group:hover .group-hover\:translate-x-0\.5{--tw-translate-x:.125rem;transform:translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.group:hover .group-hover\:opacity-100{opacity:1}@media (prefers-reduced-motion:reduce){.motion-reduce\:animate-none{animation:none}.motion-reduce\:transition-none{transition-property:none}.motion-reduce\:hover\:translate-y-0:hover{--tw-translate-y:0px;transform:translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}}.dark\:divide-white\/5:is(.dark *)>:not([hidden])~:not([hidden]){border-color:#ffffff0d}.dark\:border-amber-400\/30:is(.dark *){border-color:#fbbf244d}.dark\:border-brand-light:is(.dark *){border-color:var(--accent-light,#5eead4)}.dark\:border-white\/10:is(.dark *){border-color:#ffffff1a}.dark\:border-white\/5:is(.dark *){border-color:#ffffff0d}.dark\:border-zinc-600:is(.dark *){--tw-border-opacity:1;border-color:rgb(82 82 91/var(--tw-border-opacity,1))}.dark\:border-zinc-700:is(.dark *){--tw-border-opacity:1;border-color:rgb(63 63 70/var(--tw-border-opacity,1))}.dark\:border-zinc-700\/50:is(.dark *){border-color:#3f3f4680}.dark\:border-zinc-700\/60:is(.dark *){border-color:#3f3f4699}.dark\:border-zinc-700\/70:is(.dark *){border-color:#3f3f46b3}.dark\:bg-amber-400\/10:is(.dark *){background-color:#fbbf241a}.dark\:bg-black\/20:is(.dark *){background-color:#0003}.dark\:bg-blue-500\/10:is(.dark *){background-color:#3b82f61a}.dark\:bg-brand-light:is(.dark *){background-color:var(--accent-light,#5eead4)}.dark\:bg-cyan-500\/10:is(.dark *){background-color:#06b6d41a}.dark\:bg-teal-500\/10:is(.dark *){background-color:#14b8a61a}.dark\:bg-white\/10:is(.dark *){background-color:#ffffff1a}.dark\:bg-zinc-100:is(.dark *){--tw-bg-opacity:1;background-color:rgb(244 244 245/var(--tw-bg-opacity,1))}.dark\:bg-zinc-600:is(.dark *){--tw-bg-opacity:1;background-color:rgb(82 82 91/var(--tw-bg-opacity,1))}.dark\:bg-zinc-600\/70:is(.dark *){background-color:#52525bb3}.dark\:bg-zinc-700:is(.dark *){--tw-bg-opacity:1;background-color:rgb(63 63 70/var(--tw-bg-opacity,1))}.dark\:bg-zinc-700\/40:is(.dark *){background-color:#3f3f4666}.dark\:bg-zinc-700\/50:is(.dark *){background-color:#3f3f4680}.dark\:bg-zinc-700\/70:is(.dark *){background-color:#3f3f46b3}.dark\:bg-zinc-700\/80:is(.dark *){background-color:#3f3f46cc}.dark\:bg-zinc-800:is(.dark *){--tw-bg-opacity:1;background-color:rgb(39 39 42/var(--tw-bg-opacity,1))}.dark\:bg-zinc-800\/40:is(.dark *){background-color:#27272a66}.dark\:bg-zinc-800\/50:is(.dark *){background-color:#27272a80}.dark\:bg-zinc-800\/60:is(.dark *){background-color:#27272a99}.dark\:bg-zinc-800\/70:is(.dark *){background-color:#27272ab3}.dark\:bg-zinc-900:is(.dark *){--tw-bg-opacity:1;background-color:rgb(24 24 27/var(--tw-bg-opacity,1))}.dark\:bg-zinc-900\/20:is(.dark *){background-color:#18181b33}.dark\:bg-zinc-900\/30:is(.dark *){background-color:#18181b4d}.dark\:bg-zinc-900\/35:is(.dark *){background-color:#18181b59}.dark\:bg-zinc-900\/70:is(.dark *){background-color:#18181bb3}.dark\:bg-zinc-900\/95:is(.dark *){background-color:#18181bf2}.dark\:fill-zinc-100:is(.dark *){fill:#f4f4f5}.dark\:text-amber-200:is(.dark *){--tw-text-opacity:1;color:rgb(253 230 138/var(--tw-text-opacity,1))}.dark\:text-amber-300:is(.dark *){--tw-text-opacity:1;color:rgb(252 211 77/var(--tw-text-opacity,1))}.dark\:text-amber-300\/70:is(.dark *){color:#fcd34db3}.dark\:text-amber-300\/80:is(.dark *){color:#fcd34dcc}.dark\:text-amber-300\/90:is(.dark *){color:#fcd34de6}.dark\:text-amber-400:is(.dark *){--tw-text-opacity:1;color:rgb(251 191 36/var(--tw-text-opacity,1))}.dark\:text-brand-light:is(.dark *){color:var(--accent-light,#5eead4)}.dark\:text-cyan-300:is(.dark *){--tw-text-opacity:1;color:rgb(103 232 249/var(--tw-text-opacity,1))}.dark\:text-emerald-200:is(.dark *){--tw-text-opacity:1;color:rgb(167 243 208/var(--tw-text-opacity,1))}.dark\:text-emerald-300:is(.dark *){--tw-text-opacity:1;color:rgb(110 231 183/var(--tw-text-opacity,1))}.dark\:text-emerald-300\/70:is(.dark *){color:#6ee7b7b3}.dark\:text-emerald-400:is(.dark *){--tw-text-opacity:1;color:rgb(52 211 153/var(--tw-text-opacity,1))}.dark\:text-emerald-400\/70:is(.dark *){color:#34d399b3}.dark\:text-orange-300:is(.dark *){--tw-text-opacity:1;color:rgb(253 186 116/var(--tw-text-opacity,1))}.dark\:text-red-300:is(.dark *){--tw-text-opacity:1;color:rgb(252 165 165/var(--tw-text-opacity,1))}.dark\:text-red-300\/80:is(.dark *){color:#fca5a5cc}.dark\:text-red-300\/90:is(.dark *){color:#fca5a5e6}.dark\:text-red-400:is(.dark *){--tw-text-opacity:1;color:rgb(248 113 113/var(--tw-text-opacity,1))}.dark\:text-sky-300:is(.dark *){--tw-text-opacity:1;color:rgb(125 211 252/var(--tw-text-opacity,1))}.dark\:text-slate-300:is(.dark *){--tw-text-opacity:1;color:rgb(203 213 225/var(--tw-text-opacity,1))}.dark\:text-teal-300:is(.dark *){--tw-text-opacity:1;color:rgb(94 234 212/var(--tw-text-opacity,1))}.dark\:text-white:is(.dark *){--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.dark\:text-zinc-100:is(.dark *){--tw-text-opacity:1;color:rgb(244 244 245/var(--tw-text-opacity,1))}.dark\:text-zinc-200:is(.dark *){--tw-text-opacity:1;color:rgb(228 228 231/var(--tw-text-opacity,1))}.dark\:text-zinc-300:is(.dark *){--tw-text-opacity:1;color:rgb(212 212 216/var(--tw-text-opacity,1))}.dark\:text-zinc-400:is(.dark *){--tw-text-opacity:1;color:rgb(161 161 170/var(--tw-text-opacity,1))}.dark\:text-zinc-500:is(.dark *){--tw-text-opacity:1;color:rgb(113 113 122/var(--tw-text-opacity,1))}.dark\:text-zinc-600:is(.dark *){--tw-text-opacity:1;color:rgb(82 82 91/var(--tw-text-opacity,1))}.dark\:text-zinc-900:is(.dark *){--tw-text-opacity:1;color:rgb(24 24 27/var(--tw-text-opacity,1))}.dark\:text-zinc-900\/90:is(.dark *){color:#18181be6}.dark\:decoration-zinc-500:is(.dark *){text-decoration-color:#71717a}.dark\:ring-white\/10:is(.dark *){--tw-ring-color:#ffffff1a}.dark\:ring-white\/5:is(.dark *){--tw-ring-color:#ffffff0d}.dark\:ring-zinc-600:is(.dark *){--tw-ring-opacity:1;--tw-ring-color:rgb(82 82 91/var(--tw-ring-opacity,1))}.dark\:ring-zinc-900:is(.dark *){--tw-ring-opacity:1;--tw-ring-color:rgb(24 24 27/var(--tw-ring-opacity,1))}.dark\:file\:bg-brand-light:is(.dark *)::file-selector-button{background-color:var(--accent-light,#5eead4)}.dark\:file\:text-zinc-900:is(.dark *)::file-selector-button{--tw-text-opacity:1;color:rgb(24 24 27/var(--tw-text-opacity,1))}.dark\:placeholder\:text-zinc-500:is(.dark *)::-moz-placeholder{--tw-text-opacity:1;color:rgb(113 113 122/var(--tw-text-opacity,1))}.dark\:placeholder\:text-zinc-500:is(.dark *)::placeholder{--tw-text-opacity:1;color:rgb(113 113 122/var(--tw-text-opacity,1))}.dark\:hover\:border-white\/20:hover:is(.dark *){border-color:#fff3}.dark\:hover\:bg-zinc-600\/50:hover:is(.dark *){background-color:#52525b80}.dark\:hover\:bg-zinc-700:hover:is(.dark *){--tw-bg-opacity:1;background-color:rgb(63 63 70/var(--tw-bg-opacity,1))}.dark\:hover\:bg-zinc-700\/60:hover:is(.dark *){background-color:#3f3f4699}.dark\:hover\:bg-zinc-800\/40:hover:is(.dark *){background-color:#27272a66}.dark\:hover\:bg-zinc-800\/50:hover:is(.dark *){background-color:#27272a80}.dark\:hover\:bg-zinc-800\/60:hover:is(.dark *){background-color:#27272a99}.dark\:hover\:bg-zinc-800\/70:hover:is(.dark *){background-color:#27272ab3}.dark\:hover\:bg-zinc-800\/80:hover:is(.dark *){background-color:#27272acc}.dark\:hover\:text-amber-100:hover:is(.dark *){--tw-text-opacity:1;color:rgb(254 243 199/var(--tw-text-opacity,1))}.dark\:hover\:text-emerald-100:hover:is(.dark *){--tw-text-opacity:1;color:rgb(209 250 229/var(--tw-text-opacity,1))}.dark\:hover\:text-red-300:hover:is(.dark *){--tw-text-opacity:1;color:rgb(252 165 165/var(--tw-text-opacity,1))}.dark\:hover\:text-zinc-200:hover:is(.dark *){--tw-text-opacity:1;color:rgb(228 228 231/var(--tw-text-opacity,1))}.dark\:hover\:text-zinc-300:hover:is(.dark *){--tw-text-opacity:1;color:rgb(212 212 216/var(--tw-text-opacity,1))}.dark\:hover\:text-zinc-50:hover:is(.dark *){--tw-text-opacity:1;color:rgb(250 250 250/var(--tw-text-opacity,1))}.dark\:hover\:decoration-zinc-300:hover:is(.dark *){text-decoration-color:#d4d4d8}@media (width>=640px){.sm\:col-span-2{grid-column:span 2/span 2}.sm\:block{display:block}.sm\:inline-block{display:inline-block}.sm\:inline{display:inline}.sm\:basis-auto{flex-basis:auto}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}}@media (width>=768px){.md\:flex{display:flex}}@media (width>=1024px){.lg\:block{display:block}.lg\:inline{display:inline}.lg\:hidden{display:none}.lg\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.lg\:grid-cols-\[1fr_19rem\]{grid-template-columns:1fr 19rem}}@media (width>=1280px){.xl\:inline{display:inline}.xl\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (width>=1536px){.\32 xl\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.\32 xl\:grid-cols-5{grid-template-columns:repeat(5,minmax(0,1fr))}} |
| #!/usr/bin/env node | ||
| /** | ||
| * discord-social.mjs - direct Discord channel publishing via an Incoming Webhook. | ||
| * | ||
| * Sibling of scripts/x-social.mjs / telegram-social.mjs: the same zero-dep, | ||
| * plan-driven, publish-straight-from-the-local-render pattern, with Discord's | ||
| * webhook posting model. | ||
| * | ||
| * Discord has NO scheduling API and NO browser OAuth for this path - a channel | ||
| * Incoming Webhook is a STATIC URL (id + token) created in the channel settings, | ||
| * so entries publish at their due time by re-running `publish-due` (driven by the | ||
| * scheduler tick), exactly like Instagram / LinkedIn / X. | ||
| * | ||
| * AUTH - a single static webhook URL, no ceremony: | ||
| * DISCORD_WEBHOOK_URL https://discord.com/api/webhooks/<id>/<token> | ||
| * Posting with ?wait=true returns the created message (so we capture its id); | ||
| * the webhook also supports GET/DELETE on its own messages (used by verify/delete). | ||
| * `connect`/`auth` is a validation handshake (GET the webhook): nothing to mint. | ||
| * | ||
| * Media uploads stream straight from the local render folder as a multipart upload | ||
| * (payload_json + files[0]); text comes from post.dcCaption (falls back to | ||
| * post.caption), the additive per-platform override pattern x uses for xCaption. | ||
| * | ||
| * Commands: | ||
| * auth | connect validate the webhook (GET it); writes nothing | ||
| * refresh no-op (the webhook URL is static) - kept for sibling parity | ||
| * validate --plan <p> [--only <id>] side-effect-free preview, never posts | ||
| * publish-due --plan <p> [--only <id>] [--dry-run] publish any due Discord entry | ||
| * status --plan <p> list Discord plan entries | ||
| * verify --plan <p> [--only <id>] read-only liveness (GET the message) | ||
| * insights --plan <p> [--only <id>] no-op (a webhook exposes no metrics) | ||
| * probe read-only health probe (GET the webhook) | ||
| * delete --id <messageId> delete a posted message (cleanup) | ||
| */ | ||
| import fs from 'node:fs'; | ||
| import path from 'node:path'; | ||
| import { fileURLToPath } from 'node:url'; | ||
| import { resolveMode, isMockableCommand } from '../lib/mode.mjs'; | ||
| import { runMockCommand } from '../lib/drivers/mock-driver.mjs'; | ||
| import { envPath } from '../lib/util.mjs'; | ||
| const __dirname = path.dirname(fileURLToPath(import.meta.url)); | ||
| const ENV_PATH = envPath(); | ||
| // Discord caps a webhook message's content at 2000 chars. | ||
| const CONTENT_LIMIT = 2000; | ||
| function readEnvRaw() { | ||
| return fs.existsSync(ENV_PATH) ? fs.readFileSync(ENV_PATH, 'utf8') : ''; | ||
| } | ||
| function readEnv(name) { | ||
| const m = readEnvRaw().match(new RegExp(`^${name}=(.+)$`, 'm')); | ||
| return m ? m[1].trim() : null; | ||
| } | ||
| const webhookUrl = () => (readEnv('DISCORD_WEBHOOK_URL') || '').trim(); | ||
| // ---------- Discord helper ---------- | ||
| async function discord(method, url, { body, form } = {}) { | ||
| const init = form ? { method, body: form } : { method, headers: body ? { 'Content-Type': 'application/json' } : {}, body: body ? JSON.stringify(body) : undefined }; | ||
| const res = await fetch(url, init); | ||
| const text = await res.text(); | ||
| let data; | ||
| try { data = text ? JSON.parse(text) : {}; } catch { data = { raw: text }; } | ||
| if (!res.ok) { | ||
| throw new Error(`Discord ${method}: HTTP ${res.status} - ${data.message || data.raw || text || 'unknown'}`); | ||
| } | ||
| return data; | ||
| } | ||
| // The webhook object (GET the base URL) carries channel_id + guild_id for permalinks. | ||
| let _hookMeta = null; | ||
| async function webhookMeta() { | ||
| if (_hookMeta) return _hookMeta; | ||
| _hookMeta = await discord('GET', webhookUrl()); | ||
| return _hookMeta; | ||
| } | ||
| // ---------- plan helpers (same shape as the sibling engines) ---------- | ||
| function loadPlan(planPath) { | ||
| const abs = path.resolve(planPath); | ||
| return { abs, plan: JSON.parse(fs.readFileSync(abs, 'utf8')) }; | ||
| } | ||
| const ENGINE_OWNED_FIELDS = ['fbPostId', 'fbReelId', 'igMediaId', 'liPostId', 'ytVideoId', 'xPostId', 'tgMessageId', 'dcMessageId', 'status', 'postedAt', 'attempts']; | ||
| async function withPlanLock(abs, fn) { | ||
| const lockDir = `${abs}.lock.d`; | ||
| for (let i = 0; ; i++) { | ||
| try { fs.mkdirSync(lockDir); break; } catch (err) { | ||
| if (err.code !== 'EEXIST') throw err; | ||
| let ageMs = 0; | ||
| try { ageMs = Date.now() - fs.statSync(lockDir).mtimeMs; } catch { continue; } | ||
| if (ageMs > 15 * 60 * 1000) { try { fs.rmdirSync(lockDir); } catch { /* racing steal */ } continue; } | ||
| if (i >= 5) throw new Error(`plan lock busy: ${lockDir}`); | ||
| await new Promise((r) => setTimeout(r, 200)); | ||
| } | ||
| } | ||
| try { return fn(); } finally { try { fs.rmdirSync(lockDir); } catch { /* released */ } } | ||
| } | ||
| async function savePlan(abs, plan, touchedIds = null) { | ||
| await withPlanLock(abs, () => { | ||
| let out = plan; | ||
| if (Array.isArray(touchedIds)) { | ||
| try { | ||
| const disk = JSON.parse(fs.readFileSync(abs, 'utf8')); | ||
| for (const id of touchedIds) { | ||
| const mem = (plan.posts || []).find((p) => p.id === id); | ||
| const target = (disk.posts || []).find((p) => p.id === id); | ||
| if (!mem || !target) continue; | ||
| for (const f of ENGINE_OWNED_FIELDS) if (mem[f] !== undefined) target[f] = mem[f]; | ||
| } | ||
| out = disk; | ||
| } catch { /* unreadable disk copy - fall back to in-memory plan */ } | ||
| } | ||
| const tmp = `${abs}.tmp-${process.pid}`; | ||
| fs.writeFileSync(tmp, `${JSON.stringify(out, null, 2)}\n`); | ||
| fs.renameSync(tmp, abs); | ||
| }); | ||
| } | ||
| function appendAttempt(post, entry) { | ||
| post.attempts = Array.isArray(post.attempts) ? post.attempts : []; | ||
| post.attempts.push(entry); | ||
| } | ||
| const RUN = { results: [] }; | ||
| let JSON_MODE = false; | ||
| let ACTOR = 'cli'; | ||
| function resolveMediaPath(plan, post) { | ||
| const root = process.env.PENDPOST_ROOT ? path.resolve(process.env.PENDPOST_ROOT) : path.resolve(__dirname, '..'); | ||
| if (post.path) { | ||
| const abs = path.isAbsolute(post.path) ? post.path : path.resolve(root, post.path); | ||
| if (fs.existsSync(abs)) return abs; | ||
| } | ||
| if (post.file) { | ||
| const rel = path.join(plan.folder || '', post.file); | ||
| const abs = path.isAbsolute(rel) ? rel : path.resolve(root, rel); | ||
| if (fs.existsSync(abs)) return abs; | ||
| } | ||
| return null; | ||
| } | ||
| const isDiscord = (post) => (post.platforms || []).includes('discord'); | ||
| const isTextPost = (post) => post.type === 'text'; | ||
| const messageText = (post) => (post.dcCaption || post.caption || '').trim(); | ||
| async function permalinkFor(post) { | ||
| if (!post.dcMessageId) return null; | ||
| try { | ||
| const meta = await webhookMeta(); | ||
| if (meta.guild_id && meta.channel_id) return `https://discord.com/channels/${meta.guild_id}/${meta.channel_id}/${post.dcMessageId}`; | ||
| } catch { /* best-effort */ } | ||
| return null; | ||
| } | ||
| // ---------- commands ---------- | ||
| async function cmdAuth() { | ||
| if (!webhookUrl()) { console.error('[err] DISCORD_WEBHOOK_URL missing in .env (create an Incoming Webhook in the channel settings).'); process.exit(2); } | ||
| const meta = await webhookMeta(); | ||
| console.log(`[ok] Webhook valid - "${meta.name}" in channel ${meta.channel_id}${meta.guild_id ? ` (guild ${meta.guild_id})` : ''}.`); | ||
| RUN.results.push({ platform: 'discord', action: 'auth', ok: true, detail: `${meta.name} -> channel ${meta.channel_id}` }); | ||
| } | ||
| async function cmdRefresh() { | ||
| console.log('[info] Discord webhook URLs are static (no refresh).'); | ||
| } | ||
| async function cmdValidate(args) { | ||
| const { plan } = loadPlan(args.plan); | ||
| console.log('================ VALIDATION ONLY - NOTHING WILL BE PUBLISHED ================'); | ||
| try { | ||
| const meta = await webhookMeta(); | ||
| console.log(`[ok] Webhook valid - "${meta.name}".`); | ||
| } catch (err) { | ||
| console.log(`[warn] webhook check failed (${err.message}). Continuing to caption preview.`); | ||
| } | ||
| const targets = (plan.posts || []).filter((p) => isDiscord(p) && (!args.only || p.id === args.only)); | ||
| if (!targets.length) { console.log('[warn] No Discord entries match.'); return; } | ||
| for (const post of targets) { | ||
| const text = messageText(post); | ||
| console.log(`\n----- ${post.id} -----`); | ||
| console.log(`[preview] type: ${post.type}`); | ||
| console.log(`[preview] text (${text.length}/${CONTENT_LIMIT}${text.length > CONTENT_LIMIT ? ' - OVER LIMIT' : ''}):`); | ||
| console.log(text); | ||
| if (!isTextPost(post)) { | ||
| const mediaPath = resolveMediaPath(plan, post); | ||
| if (!mediaPath) console.log(`[warn] media not found (${post.path || post.file}).`); | ||
| else console.log(`[preview] media: ${path.basename(mediaPath)} (${(fs.statSync(mediaPath).size / 1e6).toFixed(1)} MB)`); | ||
| } | ||
| } | ||
| console.log('\n================ VALIDATION COMPLETE ================'); | ||
| } | ||
| async function cmdPublishDue(args) { | ||
| const { abs, plan } = loadPlan(args.plan); | ||
| if (!webhookUrl()) throw new Error('DISCORD_WEBHOOK_URL is not set - cannot publish.'); | ||
| const postUrl = `${webhookUrl()}?wait=true`; | ||
| const now = Date.now(); | ||
| let published = 0; | ||
| for (const post of plan.posts || []) { | ||
| if (args.only && post.id !== args.only) continue; | ||
| if (!isDiscord(post)) continue; | ||
| if (post.executionMode !== 'fully-scheduled') continue; | ||
| if (post.status !== 'planned') continue; | ||
| if ((post.approval || 'draft') !== 'approved') { | ||
| console.log(`[skip] ${post.id}: approval is "${post.approval || 'draft'}" - only approved posts publish.`); | ||
| continue; | ||
| } | ||
| const dueMs = Date.parse(post.scheduledAt); | ||
| if (Number.isNaN(dueMs) || dueMs > now) continue; | ||
| const text = messageText(post); | ||
| const textPost = isTextPost(post); | ||
| if (textPost && !text) { console.log(`[warn] ${post.id}: due but no text (dcCaption/caption) - skipping.`); continue; } | ||
| if (text.length > CONTENT_LIMIT) { console.log(`[warn] ${post.id}: text is ${text.length} chars (> ${CONTENT_LIMIT}) - skipping.`); continue; } | ||
| let mediaPath = null; | ||
| if (!textPost) { | ||
| mediaPath = resolveMediaPath(plan, post); | ||
| if (!mediaPath) { console.log(`[warn] ${post.id}: due but local media not found (${post.path || post.file}) - skipping.`); continue; } | ||
| } | ||
| if (args['dry-run']) { | ||
| console.log(textPost ? `[dry] ${post.id}: would post a message (${text.length} chars).` : `[dry] ${post.id}: would upload ${path.basename(mediaPath)} + content.`); | ||
| continue; | ||
| } | ||
| console.log(`[info] ${post.id}: publishing ${textPost ? 'message' : 'media'} to Discord...`); | ||
| try { | ||
| let result; | ||
| if (textPost) { | ||
| result = await discord('POST', postUrl, { body: { content: text } }); | ||
| } else { | ||
| const form = new FormData(); | ||
| form.append('payload_json', JSON.stringify({ content: text || '' })); | ||
| form.append('files[0]', new Blob([fs.readFileSync(mediaPath)]), path.basename(mediaPath)); | ||
| result = await discord('POST', postUrl, { form }); | ||
| } | ||
| const messageId = result?.id; | ||
| if (!messageId) throw new Error(`post returned no message id: ${JSON.stringify(result).slice(0, 200)}`); | ||
| post.dcMessageId = String(messageId); | ||
| post.status = 'posted'; | ||
| post.postedAt = new Date(now).toISOString(); | ||
| appendAttempt(post, { ts: new Date().toISOString(), platform: 'discord', action: 'publish', ok: true, errorCode: null, errorMessage: null, actor: ACTOR }); | ||
| await savePlan(abs, plan, [post.id]); | ||
| RUN.results.push({ postId: post.id, platform: 'discord', action: 'publish', ok: true, id: String(messageId) }); | ||
| console.log(`[ok] ${post.id}: published on Discord (message ${messageId}).`); | ||
| published += 1; | ||
| } catch (err) { | ||
| appendAttempt(post, { ts: new Date().toISOString(), platform: 'discord', action: 'publish', ok: false, errorCode: 'engine_failure', errorMessage: err.message.slice(0, 300), actor: ACTOR }); | ||
| await savePlan(abs, plan, [post.id]); | ||
| RUN.results.push({ postId: post.id, platform: 'discord', action: 'publish', ok: false, errorCode: 'engine_failure', errorMessage: err.message.slice(0, 300) }); | ||
| console.error(`[err] ${post.id}: Discord publish failed - ${err.message}`); | ||
| continue; | ||
| } | ||
| } | ||
| console.log(`[done] publish-due complete - ${published} message(s) published.`); | ||
| } | ||
| async function cmdStatus(args) { | ||
| const { plan } = loadPlan(args.plan); | ||
| console.log('[info] Discord plan entries:'); | ||
| for (const post of (plan.posts || []).filter(isDiscord)) { | ||
| console.log(` ${post.id.padEnd(18)} ${String(post.status).padEnd(10)} ${post.scheduledAt} mode=${post.executionMode}${post.dcMessageId ? ` dc=${post.dcMessageId}` : ''}`); | ||
| } | ||
| } | ||
| async function cmdVerify(args) { | ||
| const { plan } = loadPlan(args.plan); | ||
| for (const post of (plan.posts || []).filter(isDiscord)) { | ||
| if (args.only && post.id !== args.only) continue; | ||
| if (!post.dcMessageId) continue; | ||
| try { | ||
| await discord('GET', `${webhookUrl()}/messages/${encodeURIComponent(post.dcMessageId)}`); | ||
| RUN.results.push({ postId: post.id, platform: 'discord', action: 'verify', ok: true, live: true, state: 'posted', permalink: await permalinkFor(post), id: post.dcMessageId }); | ||
| } catch (err) { | ||
| const missing = /404|not found|unknown message/i.test(err.message || ''); | ||
| RUN.results.push({ postId: post.id, platform: 'discord', action: 'verify', ok: true, live: false, state: missing ? 'missing' : 'unknown', permalink: null, id: post.dcMessageId, errorMessage: String(err.message).slice(0, 200) }); | ||
| } | ||
| } | ||
| } | ||
| // A Discord webhook exposes no engagement metrics - honest no-op. | ||
| async function cmdInsights(args) { | ||
| const { plan } = loadPlan(args.plan); | ||
| void plan; | ||
| console.log('[info] Discord webhooks expose no per-post metrics - insights is a no-op.'); | ||
| } | ||
| async function cmdDelete(args) { | ||
| if (!args.id) { console.error('[err] delete requires --id <messageId>'); process.exit(2); } | ||
| await discord('DELETE', `${webhookUrl()}/messages/${encodeURIComponent(args.id)}`); | ||
| RUN.results.push({ platform: 'discord', action: 'delete', ok: true, id: String(args.id) }); | ||
| console.log(`[ok] deleted Discord message ${args.id}.`); | ||
| } | ||
| async function cmdProbe() { | ||
| if (!webhookUrl()) { | ||
| RUN.results.push({ platform: 'discord', action: 'probe', ok: false, detail: 'not configured (DISCORD_WEBHOOK_URL missing)' }); | ||
| return; | ||
| } | ||
| try { | ||
| const meta = await webhookMeta(); | ||
| RUN.results.push({ platform: 'discord', action: 'probe', ok: true, detail: `connected to "${meta.name}"`, tokenExpiresAt: null }); | ||
| } catch (err) { | ||
| RUN.results.push({ platform: 'discord', action: 'probe', ok: false, detail: String(err.message || err).slice(0, 200) }); | ||
| } | ||
| } | ||
| // ---------- main ---------- | ||
| function parseArgs(argv) { | ||
| const args = { _: [] }; | ||
| for (let i = 2; i < argv.length; i++) { | ||
| const a = argv[i]; | ||
| if (a.startsWith('--')) { | ||
| const key = a.slice(2); | ||
| const next = argv[i + 1]; | ||
| if (next === undefined || next.startsWith('--')) args[key] = true; | ||
| else args[key] = argv[++i]; | ||
| } else args._.push(a); | ||
| } | ||
| return args; | ||
| } | ||
| const COMMANDS = { | ||
| auth: cmdAuth, | ||
| connect: cmdAuth, | ||
| refresh: cmdRefresh, | ||
| validate: cmdValidate, | ||
| 'publish-due': cmdPublishDue, | ||
| status: cmdStatus, | ||
| verify: cmdVerify, | ||
| insights: cmdInsights, | ||
| delete: cmdDelete, | ||
| probe: cmdProbe, | ||
| }; | ||
| async function main() { | ||
| const args = parseArgs(process.argv); | ||
| JSON_MODE = Boolean(args.json); | ||
| ACTOR = typeof args.actor === 'string' ? args.actor : 'cli'; | ||
| if (JSON_MODE) console.log = (...a) => console.error(...a); | ||
| const commandName = args._[0]; | ||
| if (resolveMode('discord') === 'mock' && isMockableCommand(commandName)) { | ||
| const envelope = await runMockCommand({ | ||
| platform: 'discord', command: commandName, | ||
| planPath: typeof args.plan === 'string' ? path.resolve(String(args.plan)) : null, | ||
| only: typeof args.only === 'string' ? args.only : null, | ||
| }); | ||
| if (JSON_MODE) process.stdout.write(`${JSON.stringify(envelope)}\n`); | ||
| else console.error(`[mock] discord ${commandName}: ${envelope.results.length} result(s)`); | ||
| return; | ||
| } | ||
| const cmd = COMMANDS[commandName]; | ||
| if (!cmd) { | ||
| console.error(`Usage: node scripts/discord-social.mjs <${Object.keys(COMMANDS).join('|')}> [options]`); | ||
| process.exit(2); | ||
| } | ||
| if (['validate', 'publish-due', 'status', 'verify', 'insights'].includes(commandName) && !args.plan) { | ||
| console.error(`[err] ${commandName} requires --plan <post-plan.json>`); | ||
| process.exit(2); | ||
| } | ||
| await cmd(args); | ||
| if (JSON_MODE) process.stdout.write(`${JSON.stringify({ ok: true, ...RUN })}\n`); | ||
| } | ||
| main().catch(async (err) => { | ||
| console.error('[err]', err.message || err); | ||
| if (JSON_MODE) process.stdout.write(`${JSON.stringify({ ok: false, error: String(err.message || err).slice(0, 300), ...RUN })}\n`); | ||
| process.exit(1); | ||
| }); |
| #!/usr/bin/env node | ||
| /** | ||
| * pinterest-social.mjs - direct Pinterest pin creation via the Pinterest API v5. | ||
| * | ||
| * Sibling of scripts/yt-social.mjs / x-social.mjs / telegram-social.mjs: the same | ||
| * zero-dep, plan-driven, publish-straight-from-the-plan pattern, with Pinterest's | ||
| * own OAuth2 authorization-code auth model. | ||
| * | ||
| * Pinterest has NO scheduling API in the public v5 surface - entries publish at | ||
| * their due time by re-running `publish-due` (driven by the scheduler tick), | ||
| * exactly like Telegram / X / Instagram. There is no native `schedule` command. | ||
| * | ||
| * AUTH - OAuth2 authorization-code with rotating refresh tokens (mirrors yt-social's | ||
| * loopback ceremony + x-social's expiry tracking): | ||
| * PINTEREST_APP_ID the app id from the Pinterest developer portal. | ||
| * PINTEREST_APP_SECRET the app secret (used for HTTP Basic on the token URL). | ||
| * PINTEREST_ACCESS_TOKEN minted at consent, short-lived (~1h), auto-refreshed. | ||
| * PINTEREST_REFRESH_TOKEN durable token used to mint fresh access tokens. | ||
| * PINTEREST_TOKEN_EXPIRES_AT epoch ms - when the access token expires. | ||
| * PINTEREST_BOARD_ID the destination board IDENTIFIER (where pins land). | ||
| * `auth`/`connect` runs a loopback http server on 127.0.0.1:8088, opens the | ||
| * consent screen, captures ?code, exchanges it (Basic auth app_id:secret) and | ||
| * persists the tokens + expiry to the active client's gitignored .env. | ||
| * | ||
| * Pin media is supplied by a PUBLIC IMAGE URL (post.imageUrl), the simplest and | ||
| * most reliable v5 create-pin path (media_source.source_type=image_url). A | ||
| * local-only render with no public URL is SKIPPED with a clear [warn] - this | ||
| * engine does not host media. Title/description come from post.pinTitle / | ||
| * post.pinDescription (falling back to post.title / post.caption), the additive | ||
| * per-platform override pattern x uses for xCaption and telegram for tgCaption. | ||
| * | ||
| * HONESTY - Pinterest "Standard access": a freshly-created Pinterest app starts on | ||
| * TRIAL access, where pins created via the API are CREATOR-ONLY (visible only to | ||
| * the authenticating account, not public, not in search/feeds). PUBLIC pins require | ||
| * the app to pass Pinterest's per-app "Standard access" review. Until that review | ||
| * passes, publish will succeed (a pin id comes back) but the pin is not publicly | ||
| * visible. This is surfaced in the playbook + beta flag for this lane. | ||
| * | ||
| * Commands: | ||
| * auth | connect [--app-id X --app-secret Y] one-time loopback OAuth ceremony | ||
| * refresh mint a fresh access token from the refresh token | ||
| * validate --plan <p> [--only <id>] side-effect-free preview, never posts | ||
| * publish-due --plan <p> [--only <id>] [--dry-run] publish any due Pinterest entry | ||
| * status --plan <p> list Pinterest plan entries | ||
| * verify --plan <p> [--only <id>] read-only liveness (GET pin) | ||
| * insights --plan <p> [--only <id>] per-pin analytics (best-effort) | ||
| * probe read-only health probe (GET user_account) | ||
| * delete --id <pinId> delete a pin (cleanup) | ||
| */ | ||
| import fs from 'node:fs'; | ||
| import path from 'node:path'; | ||
| import http from 'node:http'; | ||
| import crypto from 'node:crypto'; | ||
| import { execFile } from 'node:child_process'; | ||
| import { fileURLToPath } from 'node:url'; | ||
| import { resolveMode, isMockableCommand } from '../lib/mode.mjs'; | ||
| import { runMockCommand } from '../lib/drivers/mock-driver.mjs'; | ||
| import { envPath } from '../lib/util.mjs'; | ||
| const __dirname = path.dirname(fileURLToPath(import.meta.url)); | ||
| // The .env lives in the ACTIVE client subtree, resolved by the shared envPath() | ||
| // (lib/util.mjs -> activeRoot()): the app sets PENDPOST_ROOT to that client root | ||
| // when it spawns us; a bare CLI run resolves the active client from data/clients.json. | ||
| const ENV_PATH = envPath(); | ||
| const AUTH_URL = 'https://www.pinterest.com/oauth/'; | ||
| const TOKEN_URL = 'https://api.pinterest.com/v5/oauth/token'; | ||
| const API = 'https://api.pinterest.com/v5'; | ||
| const SCOPES = 'pins:read,pins:write,boards:read'; | ||
| const DEFAULT_REDIRECT = 'http://127.0.0.1:8088/oauth/pinterest/callback'; | ||
| // Pinterest pin caps: a title at 100 chars, a description at 800. | ||
| const TITLE_LIMIT = 100; | ||
| const DESCRIPTION_LIMIT = 800; | ||
| // Refresh when the access token expires within this window (it lasts ~1h). | ||
| const REFRESH_BUFFER_MS = 5 * 60 * 1000; | ||
| // redirect uri is a constant overridable by env (mirrors yt-social's redirectUri). | ||
| const redirectUri = () => readEnv('PINTEREST_REDIRECT_URI') || DEFAULT_REDIRECT; | ||
| // ---------- env helpers (same shape as the sibling engines) ---------- | ||
| function readEnvRaw() { | ||
| return fs.existsSync(ENV_PATH) ? fs.readFileSync(ENV_PATH, 'utf8') : ''; | ||
| } | ||
| function readEnv(name) { | ||
| const m = readEnvRaw().match(new RegExp(`^${name}=(.+)$`, 'm')); | ||
| return m ? m[1].trim() : null; | ||
| } | ||
| function writeEnv(vars) { | ||
| let raw = readEnvRaw(); | ||
| for (const [k, v] of Object.entries(vars)) { | ||
| if (v == null) continue; | ||
| // function replacer: token values may contain '$', which is special in a string replacement. | ||
| if (new RegExp(`^${k}=`, 'm').test(raw)) { | ||
| raw = raw.replace(new RegExp(`^${k}=.*$`, 'm'), () => `${k}=${v}`); | ||
| } else { | ||
| raw += `${raw.endsWith('\n') || raw === '' ? '' : '\n'}${k}=${v}\n`; | ||
| } | ||
| } | ||
| // Atomic + 0600: a crash mid-write must never truncate the secret-bearing .env. | ||
| const tmp = `${ENV_PATH}.tmp-${process.pid}`; | ||
| fs.writeFileSync(tmp, raw, { mode: 0o600 }); | ||
| fs.renameSync(tmp, ENV_PATH); | ||
| } | ||
| function requireEnv(name) { | ||
| const v = readEnv(name); | ||
| if (!v) { | ||
| console.error(`[err] ${name} missing in .env - run 'node scripts/pinterest-social.mjs auth' first.`); | ||
| process.exit(1); | ||
| } | ||
| return v; | ||
| } | ||
| function tokenTail(t) { | ||
| return t ? `...${t.slice(-6)}, length ${t.length}` : '(none)'; | ||
| } | ||
| const boardId = () => readEnv('PINTEREST_BOARD_ID'); | ||
| // authenticate with HTTP Basic app_id:app_secret on the token endpoint. | ||
| function basicAuthHeader() { | ||
| const id = requireEnv('PINTEREST_APP_ID'); | ||
| const secret = requireEnv('PINTEREST_APP_SECRET'); | ||
| return `Basic ${Buffer.from(`${id}:${secret}`).toString('base64')}`; | ||
| } | ||
| // ---------- oauth ---------- | ||
| async function tokenExchange(params) { | ||
| const res = await fetch(TOKEN_URL, { | ||
| method: 'POST', | ||
| headers: { | ||
| 'Content-Type': 'application/x-www-form-urlencoded', | ||
| Authorization: basicAuthHeader(), | ||
| }, | ||
| body: new URLSearchParams(params), | ||
| }); | ||
| const data = await res.json().catch(() => ({})); | ||
| if (!res.ok || data.error) { | ||
| const hint = data.error === 'invalid_grant' | ||
| ? " - the refresh token is expired/revoked. Re-run 'node scripts/pinterest-social.mjs auth'." | ||
| : ''; | ||
| throw new Error(`OAuth ${params.grant_type}: HTTP ${res.status} ${data.error || ''} - ${data.error_description || data.message || JSON.stringify(data)}${hint}`); | ||
| } | ||
| return data; | ||
| } | ||
| // Persist a token-endpoint response and return the vars written (so the caller can | ||
| // log the new expiry). Pinterest returns access_token (+ expires_in) and, on a | ||
| // rotating-refresh response, a fresh refresh_token + refresh_token_expires_in. | ||
| function persistTokens(data) { | ||
| const vars = { | ||
| PINTEREST_ACCESS_TOKEN: data.access_token, | ||
| PINTEREST_TOKEN_EXPIRES_AT: String(Date.now() + (Number(data.expires_in) || 0) * 1000), | ||
| }; | ||
| if (data.refresh_token) vars.PINTEREST_REFRESH_TOKEN = data.refresh_token; | ||
| writeEnv(vars); | ||
| return vars; | ||
| } | ||
| // Return a valid access token, refreshing it (rotating the stored refresh token) | ||
| // if the current one expires within REFRESH_BUFFER_MS. Throws (never process.exit) | ||
| // so main().catch can emit the --json failure envelope and the probe path can catch. | ||
| async function ensureFreshToken({ force = false } = {}) { | ||
| const token = readEnv('PINTEREST_ACCESS_TOKEN'); | ||
| const expiresAt = Number(readEnv('PINTEREST_TOKEN_EXPIRES_AT') || 0); | ||
| if (!token && !readEnv('PINTEREST_REFRESH_TOKEN')) { | ||
| throw new Error("No PINTEREST_ACCESS_TOKEN/PINTEREST_REFRESH_TOKEN - run 'node scripts/pinterest-social.mjs auth' first."); | ||
| } | ||
| if (token && !force && expiresAt - Date.now() > REFRESH_BUFFER_MS) return token; | ||
| const refreshToken = readEnv('PINTEREST_REFRESH_TOKEN'); | ||
| if (!refreshToken) { | ||
| if (token && !force && expiresAt > Date.now()) return token; | ||
| throw new Error("Pinterest access token expired and no refresh token is stored - re-run 'node scripts/pinterest-social.mjs auth'."); | ||
| } | ||
| console.log('[info] Refreshing Pinterest access token...'); | ||
| let data; | ||
| try { | ||
| data = await tokenExchange({ grant_type: 'refresh_token', refresh_token: refreshToken }); | ||
| } catch (err) { | ||
| throw new Error(`Pinterest token refresh failed (${err.message}). The refresh token likely expired or was revoked - re-run 'node scripts/pinterest-social.mjs auth'.`); | ||
| } | ||
| const vars = persistTokens(data); | ||
| console.log(`[ok] Token refreshed ${tokenTail(data.access_token)}, expires ${new Date(Number(vars.PINTEREST_TOKEN_EXPIRES_AT)).toLocaleString('en-US')}.`); | ||
| return data.access_token; | ||
| } | ||
| // ---------- pinterest v5 api helper (json GET/POST/DELETE) ---------- | ||
| async function api(method, pathname, { query, body, token } = {}) { | ||
| const url = new URL(`${API}${pathname}`); | ||
| if (query) for (const [k, v] of Object.entries(query)) url.searchParams.set(k, String(v)); | ||
| const headers = { Authorization: `Bearer ${token}` }; | ||
| let payload; | ||
| if (body !== undefined) { headers['Content-Type'] = 'application/json'; payload = JSON.stringify(body); } | ||
| const res = await fetch(url, { method, headers, body: payload }); | ||
| const text = await res.text(); | ||
| let data; | ||
| try { data = text ? JSON.parse(text) : {}; } catch { data = { raw: text }; } | ||
| if (!res.ok) { | ||
| throw new Error(`Pinterest ${method} ${pathname}: HTTP ${res.status} - ${data.message || data.error_description || text || ''}`); | ||
| } | ||
| return data; | ||
| } | ||
| // ---------- plan helpers (same shape as the sibling engines) ---------- | ||
| // (lock + field-merge save duplicated verbatim across the engine siblings - | ||
| // self-contained per the sibling pattern, no shared lib) | ||
| function loadPlan(planPath) { | ||
| const abs = path.resolve(planPath); | ||
| return { abs, plan: JSON.parse(fs.readFileSync(abs, 'utf8')) }; | ||
| } | ||
| // Engine-owned fields; everything else (caption, schedule, approval, cover) | ||
| // belongs to the owner/pendpost and must survive concurrent edits. | ||
| const ENGINE_OWNED_FIELDS = ['fbPostId', 'fbReelId', 'igMediaId', 'liPostId', 'ytVideoId', 'xPostId', 'tgMessageId', 'dcMessageId', 'pinId', 'status', 'postedAt', 'attempts']; | ||
| // mkdir lockfile next to the plan: retry 5x200ms, steal when stale (>15 min). | ||
| async function withPlanLock(abs, fn) { | ||
| const lockDir = `${abs}.lock.d`; | ||
| for (let i = 0; ; i++) { | ||
| try { fs.mkdirSync(lockDir); break; } catch (err) { | ||
| if (err.code !== 'EEXIST') throw err; | ||
| let ageMs = 0; | ||
| try { ageMs = Date.now() - fs.statSync(lockDir).mtimeMs; } catch { continue; } | ||
| if (ageMs > 15 * 60 * 1000) { try { fs.rmdirSync(lockDir); } catch { /* racing steal */ } continue; } | ||
| if (i >= 5) throw new Error(`plan lock busy: ${lockDir}`); | ||
| await new Promise((r) => setTimeout(r, 200)); | ||
| } | ||
| } | ||
| try { return fn(); } finally { try { fs.rmdirSync(lockDir); } catch { /* released */ } } | ||
| } | ||
| async function savePlan(abs, plan, touchedIds = null) { | ||
| await withPlanLock(abs, () => { | ||
| let out = plan; | ||
| if (Array.isArray(touchedIds)) { | ||
| try { | ||
| const disk = JSON.parse(fs.readFileSync(abs, 'utf8')); | ||
| for (const id of touchedIds) { | ||
| const mem = (plan.posts || []).find((p) => p.id === id); | ||
| const target = (disk.posts || []).find((p) => p.id === id); | ||
| if (!mem || !target) continue; | ||
| for (const f of ENGINE_OWNED_FIELDS) if (mem[f] !== undefined) target[f] = mem[f]; | ||
| } | ||
| out = disk; | ||
| } catch { /* unreadable disk copy - fall back to in-memory plan */ } | ||
| } | ||
| const tmp = `${abs}.tmp-${process.pid}`; | ||
| fs.writeFileSync(tmp, `${JSON.stringify(out, null, 2)}\n`); | ||
| fs.renameSync(tmp, abs); | ||
| }); | ||
| } | ||
| function appendAttempt(post, entry) { | ||
| post.attempts = Array.isArray(post.attempts) ? post.attempts : []; | ||
| post.attempts.push(entry); | ||
| } | ||
| const RUN = { results: [] }; | ||
| let JSON_MODE = false; | ||
| let ACTOR = 'cli'; | ||
| const isPinterest = (post) => (post.platforms || []).includes('pinterest'); | ||
| const pinTitle = (post) => (post.pinTitle || post.title || '').trim(); | ||
| const pinDescription = (post) => (post.pinDescription || post.caption || '').trim(); | ||
| // Media is supplied by a PUBLIC image url - this engine does not host media. | ||
| const pinImageUrl = (post) => (post.imageUrl || '').trim(); | ||
| function permalinkFor(post) { | ||
| return post.pinId ? `https://www.pinterest.com/pin/${post.pinId}/` : null; | ||
| } | ||
| // ---------- commands ---------- | ||
| async function cmdAuth(args) { | ||
| console.log(`[info] Connecting Pinterest - credentials will be written to ${ENV_PATH}`); | ||
| const appId = args['app-id'] || readEnv('PINTEREST_APP_ID'); | ||
| const appSecret = args['app-secret'] || readEnv('PINTEREST_APP_SECRET'); | ||
| if (!appId || !appSecret) { | ||
| console.error('[err] Need --app-id and --app-secret (Pinterest developer portal -> your app) on first run, or set PINTEREST_APP_ID / PINTEREST_APP_SECRET in .env.'); | ||
| process.exit(2); | ||
| } | ||
| const redirect = redirectUri(); | ||
| writeEnv({ PINTEREST_APP_ID: appId, PINTEREST_APP_SECRET: appSecret, PINTEREST_REDIRECT_URI: redirect }); | ||
| const u = new URL(redirect); | ||
| const port = Number(u.port || 80); | ||
| const callbackPath = u.pathname || '/oauth/pinterest/callback'; | ||
| const state = crypto.randomUUID(); | ||
| const authUrl = `${AUTH_URL}?${new URLSearchParams({ | ||
| response_type: 'code', | ||
| client_id: appId, | ||
| redirect_uri: redirect, | ||
| scope: SCOPES, | ||
| state, | ||
| }).toString()}`; | ||
| console.log(`\n[action] Make sure ${redirect} is listed as a Redirect URI for this Pinterest app (developer portal -> your app -> Configure).`); | ||
| console.log('[action] Opening the Pinterest consent screen. Sign in with the account that owns your destination board. If it does not open, paste this URL:\n'); | ||
| console.log(` ${authUrl}\n`); | ||
| await new Promise((resolve, reject) => { | ||
| const server = http.createServer(async (req, res) => { | ||
| const ru = new URL(req.url, redirect); | ||
| if (ru.pathname !== callbackPath) { res.writeHead(404); res.end('not found'); return; } | ||
| const error = ru.searchParams.get('error'); | ||
| if (error) { | ||
| res.writeHead(200, { 'Content-Type': 'text/html' }); | ||
| res.end(`<h2>Authorization denied: ${error}</h2><p>${ru.searchParams.get('error_description') || ''}</p>`); | ||
| server.close(); | ||
| reject(new Error(`Authorization denied: ${error} - ${ru.searchParams.get('error_description') || ''}`)); | ||
| return; | ||
| } | ||
| const code = ru.searchParams.get('code'); | ||
| if (!code) { res.writeHead(400); res.end('missing code'); return; } | ||
| if (ru.searchParams.get('state') !== state) { | ||
| res.writeHead(400); res.end('state mismatch'); | ||
| server.close(); | ||
| reject(new Error('OAuth state mismatch - possible CSRF; aborted.')); | ||
| return; | ||
| } | ||
| try { | ||
| const data = await tokenExchange({ | ||
| grant_type: 'authorization_code', | ||
| code, | ||
| redirect_uri: redirect, | ||
| }); | ||
| if (!data.access_token) { | ||
| throw new Error(`No access_token returned: ${JSON.stringify(data).slice(0, 200)}`); | ||
| } | ||
| const vars = persistTokens(data); | ||
| res.writeHead(200, { 'Content-Type': 'text/html' }); | ||
| res.end('<h2>pendpost: Pinterest connected.</h2><p>You can close this tab and return to the terminal.</p>'); | ||
| const exp = new Date(Number(vars.PINTEREST_TOKEN_EXPIRES_AT)).toLocaleString('en-US'); | ||
| console.log(`\n[ok] Access token stored ${tokenTail(data.access_token)}, expires ${exp}.`); | ||
| console.log(`[ok] Refresh token ${data.refresh_token ? 'stored - rotating-refresh enabled.' : 'NOT issued - re-auth may be needed when the access token expires.'}`); | ||
| server.close(); | ||
| resolve(); | ||
| } catch (err) { | ||
| res.writeHead(500, { 'Content-Type': 'text/html' }); | ||
| res.end(`<h2>Token exchange failed</h2><pre>${err.message}</pre>`); | ||
| server.close(); | ||
| reject(err); | ||
| } | ||
| }); | ||
| server.on('error', reject); | ||
| server.listen(port, () => { | ||
| execFile('open', [authUrl], () => {}); // best-effort browser open on macOS (no shell -> no injection) | ||
| console.log(`[info] Waiting for the Pinterest consent redirect on ${redirect} ...`); | ||
| }); | ||
| }); | ||
| // prove the token works and surface the account. | ||
| try { | ||
| const token = await ensureFreshToken(); | ||
| const me = await api('GET', '/user_account', { token }); | ||
| console.log(`[ok] Pinterest account: ${me.username || me.business_name || '(unknown)'}.`); | ||
| const bid = boardId(); | ||
| if (!bid) console.log('[warn] PINTEREST_BOARD_ID is not set - set it to the destination board id before publishing.'); | ||
| } catch (err) { | ||
| console.log(`[warn] Could not fetch the account: ${err.message}`); | ||
| } | ||
| console.log('[note] New Pinterest apps start on TRIAL access - API-created pins are CREATOR-ONLY until the app passes Pinterest "Standard access" review (then pins are public).'); | ||
| console.log('[done] auth complete.'); | ||
| } | ||
| async function cmdRefresh() { | ||
| const token = await ensureFreshToken({ force: true }); | ||
| RUN.results.push({ platform: 'pinterest', action: 'refresh', ok: true, tokenExpiresAt: Number(readEnv('PINTEREST_TOKEN_EXPIRES_AT') || 0) || null }); | ||
| console.log(`[ok] Access token ${tokenTail(token)}, expires ${new Date(Number(readEnv('PINTEREST_TOKEN_EXPIRES_AT'))).toLocaleString('en-US')}.`); | ||
| } | ||
| async function cmdValidate(args) { | ||
| const { plan } = loadPlan(args.plan); | ||
| console.log('================ VALIDATION ONLY - NOTHING WILL BE PUBLISHED ================'); | ||
| try { | ||
| const token = await ensureFreshToken(); | ||
| const me = await api('GET', '/user_account', { token }); | ||
| console.log(`[ok] Token valid - authenticated as ${me.username || me.business_name || '(unknown)'}.`); | ||
| } catch (err) { | ||
| console.log(`[warn] account check failed (${err.message}). Continuing to content preview.`); | ||
| } | ||
| const bid = boardId(); | ||
| if (!bid) console.log('[warn] PINTEREST_BOARD_ID is not set - publish-due would skip every entry.'); | ||
| const targets = (plan.posts || []).filter((p) => isPinterest(p) && (!args.only || p.id === args.only)); | ||
| if (!targets.length) { console.log('[warn] No Pinterest entries match.'); return; } | ||
| for (const post of targets) { | ||
| const title = pinTitle(post); | ||
| const desc = pinDescription(post); | ||
| const url = pinImageUrl(post); | ||
| console.log(`\n----- ${post.id} -----`); | ||
| console.log(`[preview] title (${title.length}/${TITLE_LIMIT}${title.length > TITLE_LIMIT ? ' - OVER LIMIT' : ''}): ${title}`); | ||
| console.log(`[preview] description (${desc.length}/${DESCRIPTION_LIMIT}${desc.length > DESCRIPTION_LIMIT ? ' - OVER LIMIT' : ''}):`); | ||
| console.log(desc); | ||
| if (!url) console.log('[warn] no public image url (post.imageUrl) - this entry would be skipped (engine does not host media).'); | ||
| else console.log(`[preview] image url: ${url}`); | ||
| } | ||
| console.log('\n================ VALIDATION COMPLETE ================'); | ||
| } | ||
| async function cmdPublishDue(args) { | ||
| const { abs, plan } = loadPlan(args.plan); | ||
| const bid = boardId(); | ||
| if (!bid) throw new Error('PINTEREST_BOARD_ID is not set - cannot publish.'); | ||
| const token = await ensureFreshToken(); | ||
| const now = Date.now(); | ||
| let published = 0; | ||
| for (const post of plan.posts || []) { | ||
| if (args.only && post.id !== args.only) continue; | ||
| if (!isPinterest(post)) continue; | ||
| if (post.executionMode !== 'fully-scheduled') continue; | ||
| if (post.status !== 'planned') continue; | ||
| if ((post.approval || 'draft') !== 'approved') { | ||
| console.log(`[skip] ${post.id}: approval is "${post.approval || 'draft'}" - only approved posts publish.`); | ||
| continue; | ||
| } | ||
| const dueMs = Date.parse(post.scheduledAt); | ||
| if (Number.isNaN(dueMs) || dueMs > now) continue; | ||
| const title = pinTitle(post); | ||
| const desc = pinDescription(post); | ||
| if (title.length > TITLE_LIMIT) { console.log(`[warn] ${post.id}: title is ${title.length} chars (> ${TITLE_LIMIT}) - skipping.`); continue; } | ||
| if (desc.length > DESCRIPTION_LIMIT) { console.log(`[warn] ${post.id}: description is ${desc.length} chars (> ${DESCRIPTION_LIMIT}) - skipping.`); continue; } | ||
| const url = pinImageUrl(post); | ||
| if (!url) { | ||
| // A local-only render with no public URL cannot become a pin: v5 create-pin | ||
| // takes media by public URL, and this engine does not host media. | ||
| console.log(`[warn] ${post.id}: due but no public image url (post.imageUrl) - skipping (Pinterest engine does not host media; supply a public url).`); | ||
| continue; | ||
| } | ||
| if (args['dry-run']) { | ||
| console.log(`[dry] ${post.id}: would create a pin on board ${bid} from ${url} (title ${title.length} / desc ${desc.length} chars).`); | ||
| continue; | ||
| } | ||
| console.log(`[info] ${post.id}: creating Pinterest pin on board ${bid}...`); | ||
| try { | ||
| const body = { | ||
| board_id: bid, | ||
| title: title || undefined, | ||
| description: desc || undefined, | ||
| media_source: { source_type: 'image_url', url }, | ||
| }; | ||
| const result = await api('POST', '/pins', { body, token }); | ||
| const pinId = result?.id; | ||
| if (!pinId) throw new Error(`create-pin returned no id: ${JSON.stringify(result).slice(0, 200)}`); | ||
| post.pinId = String(pinId); | ||
| post.status = 'posted'; | ||
| post.postedAt = new Date(now).toISOString(); | ||
| appendAttempt(post, { ts: new Date().toISOString(), platform: 'pinterest', action: 'publish', ok: true, errorCode: null, errorMessage: null, actor: ACTOR }); | ||
| await savePlan(abs, plan, [post.id]); | ||
| RUN.results.push({ postId: post.id, platform: 'pinterest', action: 'publish', ok: true, id: String(pinId) }); | ||
| console.log(`[ok] ${post.id}: published on Pinterest (pin ${pinId}).`); | ||
| published += 1; | ||
| } catch (err) { | ||
| appendAttempt(post, { ts: new Date().toISOString(), platform: 'pinterest', action: 'publish', ok: false, errorCode: 'engine_failure', errorMessage: err.message.slice(0, 300), actor: ACTOR }); | ||
| await savePlan(abs, plan, [post.id]); | ||
| RUN.results.push({ postId: post.id, platform: 'pinterest', action: 'publish', ok: false, errorCode: 'engine_failure', errorMessage: err.message.slice(0, 300) }); | ||
| console.error(`[err] ${post.id}: Pinterest publish failed - ${err.message}`); | ||
| continue; | ||
| } | ||
| } | ||
| console.log(`[done] publish-due complete - ${published} pin(s) published.`); | ||
| } | ||
| async function cmdStatus(args) { | ||
| const { plan } = loadPlan(args.plan); | ||
| console.log('[info] Pinterest plan entries:'); | ||
| for (const post of (plan.posts || []).filter(isPinterest)) { | ||
| console.log(` ${post.id.padEnd(18)} ${String(post.status).padEnd(10)} ${post.scheduledAt} mode=${post.executionMode}${post.pinId ? ` pin=${post.pinId}` : ''}`); | ||
| } | ||
| } | ||
| // Read-only liveness: GET the stored pin. A 200 means the pin exists; the public | ||
| // permalink is derivable from the id (note: the pin is only publicly reachable | ||
| // once the app has Pinterest "Standard access"). | ||
| async function cmdVerify(args) { | ||
| const { plan } = loadPlan(args.plan); | ||
| let token = null; | ||
| try { token = await ensureFreshToken(); } catch { /* surfaced per row */ } | ||
| for (const post of (plan.posts || []).filter(isPinterest)) { | ||
| if (args.only && post.id !== args.only) continue; | ||
| if (!post.pinId) continue; | ||
| if (!token) { | ||
| RUN.results.push({ postId: post.id, platform: 'pinterest', action: 'verify', ok: true, live: false, state: 'unknown', permalink: null, id: post.pinId }); | ||
| continue; | ||
| } | ||
| try { | ||
| await api('GET', `/pins/${post.pinId}`, { token }); | ||
| RUN.results.push({ postId: post.id, platform: 'pinterest', action: 'verify', ok: true, live: true, state: 'live', permalink: permalinkFor(post), id: post.pinId }); | ||
| } catch (err) { | ||
| RUN.results.push({ postId: post.id, platform: 'pinterest', action: 'verify', ok: false, errorCode: 'verify_failed', errorMessage: String(err.message || err).slice(0, 200), live: false, state: 'unknown', id: post.pinId }); | ||
| } | ||
| } | ||
| } | ||
| // Per-pin analytics: GET /pins/<id>/analytics. Best-effort - the endpoint requires | ||
| // the analytics scope/Standard access and 7 days of data, so failures degrade to an | ||
| // honest ok:false row rather than fabricating metrics. | ||
| async function cmdInsights(args) { | ||
| const { plan } = loadPlan(args.plan); | ||
| let token = null; | ||
| try { token = await ensureFreshToken(); } catch (err) { console.log(`[warn] insights: ${err.message}`); return; } | ||
| for (const post of (plan.posts || []).filter(isPinterest)) { | ||
| if (args.only && post.id !== args.only) continue; | ||
| if (!post.pinId) continue; | ||
| try { | ||
| const end = new Date(); | ||
| const start = new Date(end.getTime() - 29 * 24 * 60 * 60 * 1000); | ||
| const fmt = (d) => d.toISOString().slice(0, 10); | ||
| const data = await api('GET', `/pins/${post.pinId}/analytics`, { | ||
| query: { start_date: fmt(start), end_date: fmt(end), metric_types: 'IMPRESSION,PIN_CLICK,SAVE,OUTBOUND_CLICK' }, | ||
| token, | ||
| }); | ||
| RUN.results.push({ postId: post.id, platform: 'pinterest', action: 'insights', ok: true, metrics: data, id: post.pinId }); | ||
| } catch (err) { | ||
| RUN.results.push({ postId: post.id, platform: 'pinterest', action: 'insights', ok: false, errorCode: 'insights_unavailable', errorMessage: String(err.message || err).slice(0, 200), id: post.pinId }); | ||
| } | ||
| } | ||
| } | ||
| async function cmdDelete(args) { | ||
| if (!args.id) { console.error('[err] delete requires --id <pinId>'); process.exit(2); } | ||
| const token = await ensureFreshToken(); | ||
| await api('DELETE', `/pins/${args.id}`, { token }); | ||
| RUN.results.push({ platform: 'pinterest', action: 'delete', ok: true, id: String(args.id) }); | ||
| console.log(`[ok] deleted Pinterest pin ${args.id}.`); | ||
| } | ||
| async function cmdProbe() { | ||
| if (!readEnv('PINTEREST_ACCESS_TOKEN') && !readEnv('PINTEREST_REFRESH_TOKEN')) { | ||
| RUN.results.push({ platform: 'pinterest', action: 'probe', ok: false, detail: 'not configured (PINTEREST_ACCESS_TOKEN/PINTEREST_REFRESH_TOKEN missing)' }); | ||
| return; | ||
| } | ||
| try { | ||
| const token = await ensureFreshToken(); | ||
| const me = await api('GET', '/user_account', { token }); | ||
| const expiresAt = Number(readEnv('PINTEREST_TOKEN_EXPIRES_AT') || 0) || null; | ||
| RUN.results.push({ platform: 'pinterest', action: 'probe', ok: true, detail: `connected as ${me.username || me.business_name || '?'}`, tokenExpiresAt: expiresAt }); | ||
| } catch (err) { | ||
| RUN.results.push({ platform: 'pinterest', action: 'probe', ok: false, detail: String(err.message || err).slice(0, 200) }); | ||
| } | ||
| } | ||
| // ---------- main ---------- | ||
| function parseArgs(argv) { | ||
| const args = { _: [] }; | ||
| for (let i = 2; i < argv.length; i++) { | ||
| const a = argv[i]; | ||
| if (a.startsWith('--')) { | ||
| const key = a.slice(2); | ||
| const next = argv[i + 1]; | ||
| if (next === undefined || next.startsWith('--')) args[key] = true; | ||
| else args[key] = argv[++i]; | ||
| } else args._.push(a); | ||
| } | ||
| return args; | ||
| } | ||
| const COMMANDS = { | ||
| auth: cmdAuth, | ||
| connect: cmdAuth, | ||
| refresh: cmdRefresh, | ||
| validate: cmdValidate, | ||
| 'publish-due': cmdPublishDue, | ||
| status: cmdStatus, | ||
| verify: cmdVerify, | ||
| insights: cmdInsights, | ||
| delete: cmdDelete, | ||
| probe: cmdProbe, | ||
| }; | ||
| async function main() { | ||
| const args = parseArgs(process.argv); | ||
| JSON_MODE = Boolean(args.json); | ||
| ACTOR = typeof args.actor === 'string' ? args.actor : 'cli'; | ||
| if (JSON_MODE) console.log = (...a) => console.error(...a); | ||
| const commandName = args._[0]; | ||
| if (resolveMode('pinterest') === 'mock' && isMockableCommand(commandName)) { | ||
| const envelope = await runMockCommand({ | ||
| platform: 'pinterest', command: commandName, | ||
| planPath: typeof args.plan === 'string' ? path.resolve(String(args.plan)) : null, | ||
| only: typeof args.only === 'string' ? args.only : null, | ||
| }); | ||
| if (JSON_MODE) process.stdout.write(`${JSON.stringify(envelope)}\n`); | ||
| else console.error(`[mock] pinterest ${commandName}: ${envelope.results.length} result(s)`); | ||
| return; | ||
| } | ||
| const cmd = COMMANDS[commandName]; | ||
| if (!cmd) { | ||
| console.error(`Usage: node scripts/pinterest-social.mjs <${Object.keys(COMMANDS).join('|')}> [options]`); | ||
| process.exit(2); | ||
| } | ||
| if (['validate', 'publish-due', 'status', 'verify', 'insights'].includes(commandName) && !args.plan) { | ||
| console.error(`[err] ${commandName} requires --plan <post-plan.json>`); | ||
| process.exit(2); | ||
| } | ||
| await cmd(args); | ||
| if (JSON_MODE) process.stdout.write(`${JSON.stringify({ ok: true, ...RUN })}\n`); | ||
| } | ||
| main().catch(async (err) => { | ||
| console.error('[err]', err.message || err); | ||
| if (JSON_MODE) process.stdout.write(`${JSON.stringify({ ok: false, error: String(err.message || err).slice(0, 300), ...RUN })}\n`); | ||
| process.exit(1); | ||
| }); |
| #!/usr/bin/env node | ||
| /** | ||
| * reddit-social.mjs - direct Reddit submission via the OAuth2 API. | ||
| * | ||
| * Sibling of scripts/telegram-social.mjs / discord-social.mjs: the same zero-dep, | ||
| * plan-driven, publish-straight-from-the-local-render pattern, with Reddit's own | ||
| * (script-app, password-grant) auth + posting model. | ||
| * | ||
| * Reddit, like Telegram, is a STATIC-credential lane: there is NO browser OAuth | ||
| * dance and NO long-lived refresh token to persist. A short-lived bearer token is | ||
| * minted on demand per run from a "script" app's client id/secret + the bot | ||
| * account's username/password (OAuth2 password grant), used, and thrown away. | ||
| * `connect`/`auth` therefore only VALIDATES the static creds - it writes nothing. | ||
| * | ||
| * Reddit has NO scheduling API, so entries publish at their due time by re-running | ||
| * `publish-due` (driven by the scheduler tick), exactly like Telegram / Discord. | ||
| * | ||
| * HONESTY / RISK (surfaced as a 'beta' flag + in the playbook): | ||
| * - Reddit's FREE data API is licensed for NON-COMMERCIAL use only. A commercial | ||
| * auto-posting workload may require a paid/commercial agreement with Reddit. | ||
| * - Automated submitting is COMMUNITY-NORM sensitive: most subreddits dislike or | ||
| * ban bot/cross-posted content, many enforce rate limits, karma/age gates, and | ||
| * per-subreddit rules. Always post only where you have permission, sparingly, | ||
| * and to a subreddit whose rules allow it (REDDIT_SUBREDDIT, e.g. "test"). | ||
| * - Reddit BANS default/blank User-Agents - every request sends a descriptive UA. | ||
| * | ||
| * AUTH - a "script" app (https://www.reddit.com/prefs/apps), no browser: | ||
| * REDDIT_CLIENT_ID the script app's client id (under the app name). | ||
| * REDDIT_CLIENT_SECRET the script app's secret. | ||
| * REDDIT_USERNAME the bot account's username (no leading u/). | ||
| * REDDIT_PASSWORD the bot account's password. | ||
| * REDDIT_SUBREDDIT the destination subreddit identifier (non-secret), e.g. | ||
| * "test". The account must be allowed to submit there. | ||
| * | ||
| * Text comes from post.redditText (falls back to post.caption); the title from | ||
| * post.title (falls back to the first non-empty line of the caption) - the | ||
| * additive per-platform override pattern x uses for xCaption. | ||
| * | ||
| * Commands: | ||
| * auth | connect mint a token + GET /api/v1/me; report the username; writes nothing | ||
| * refresh no-op (token is minted per run, never persisted) - sibling parity | ||
| * validate --plan <p> [--only <id>] side-effect-free preview, never posts | ||
| * publish-due --plan <p> [--only <id>] [--dry-run] publish any due Reddit entry | ||
| * status --plan <p> list Reddit plan entries | ||
| * verify --plan <p> [--only <id>] read-only liveness (best-effort) | ||
| * insights --plan <p> [--only <id>] minimal honest metrics (score/comments) | ||
| * probe read-only health probe (token + /me) | ||
| * delete --id <fullname> delete a submission (t3_...) cleanup | ||
| */ | ||
| import fs from 'node:fs'; | ||
| import path from 'node:path'; | ||
| import { fileURLToPath } from 'node:url'; | ||
| import { resolveMode, isMockableCommand } from '../lib/mode.mjs'; | ||
| import { runMockCommand } from '../lib/drivers/mock-driver.mjs'; | ||
| import { envPath } from '../lib/util.mjs'; | ||
| const __dirname = path.dirname(fileURLToPath(import.meta.url)); | ||
| const ENV_PATH = envPath(); | ||
| // Reddit caps: a self-post title at 300 chars, a self-post body at 40000. | ||
| const TITLE_LIMIT = 300; | ||
| const TEXT_LIMIT = 40000; | ||
| // ---------- env helpers (same shape as the sibling engines) ---------- | ||
| function readEnvRaw() { | ||
| return fs.existsSync(ENV_PATH) ? fs.readFileSync(ENV_PATH, 'utf8') : ''; | ||
| } | ||
| function readEnv(name) { | ||
| const m = readEnvRaw().match(new RegExp(`^${name}=(.+)$`, 'm')); | ||
| return m ? m[1].trim() : null; | ||
| } | ||
| const subreddit = () => readEnv('REDDIT_SUBREDDIT'); | ||
| // Reddit BANS default/blank User-Agents - send a descriptive UA on EVERY request. | ||
| function userAgent() { | ||
| const u = readEnv('REDDIT_USERNAME') || 'unknown'; | ||
| return `pendpost/1.0 (by /u/${u})`; | ||
| } | ||
| // ---------- OAuth2 (password grant, minted on demand, never persisted) ---------- | ||
| async function mintToken() { | ||
| const clientId = readEnv('REDDIT_CLIENT_ID'); | ||
| const clientSecret = readEnv('REDDIT_CLIENT_SECRET'); | ||
| const username = readEnv('REDDIT_USERNAME'); | ||
| const password = readEnv('REDDIT_PASSWORD'); | ||
| const missing = []; | ||
| if (!clientId) missing.push('REDDIT_CLIENT_ID'); | ||
| if (!clientSecret) missing.push('REDDIT_CLIENT_SECRET'); | ||
| if (!username) missing.push('REDDIT_USERNAME'); | ||
| if (!password) missing.push('REDDIT_PASSWORD'); | ||
| if (missing.length) throw new Error(`Reddit credentials missing in .env: ${missing.join(', ')}`); | ||
| const basic = Buffer.from(`${clientId}:${clientSecret}`).toString('base64'); | ||
| const body = new URLSearchParams({ grant_type: 'password', username, password }).toString(); | ||
| const res = await fetch('https://www.reddit.com/api/v1/access_token', { | ||
| method: 'POST', | ||
| headers: { | ||
| Authorization: `Basic ${basic}`, | ||
| 'Content-Type': 'application/x-www-form-urlencoded', | ||
| 'User-Agent': userAgent(), | ||
| }, | ||
| body, | ||
| }); | ||
| const text = await res.text(); | ||
| let data; | ||
| try { data = text ? JSON.parse(text) : {}; } catch { data = { raw: text }; } | ||
| if (!res.ok || !data.access_token) { | ||
| // Never echo the password/secret - only Reddit's own error string. | ||
| throw new Error(`Reddit token mint failed: HTTP ${res.status} - ${data.error || data.message || data.raw || 'unknown'}`); | ||
| } | ||
| return data.access_token; | ||
| } | ||
| // Authenticated call against the oauth.reddit.com host. | ||
| async function reddit(token, method, pathname, { body, form } = {}) { | ||
| const url = `https://oauth.reddit.com${pathname}`; | ||
| const headers = { Authorization: `Bearer ${token}`, 'User-Agent': userAgent() }; | ||
| let init = { method, headers }; | ||
| if (form) { | ||
| init.body = new URLSearchParams(form).toString(); | ||
| headers['Content-Type'] = 'application/x-www-form-urlencoded'; | ||
| } else if (body !== undefined) { | ||
| init.body = JSON.stringify(body); | ||
| headers['Content-Type'] = 'application/json'; | ||
| } | ||
| const res = await fetch(url, init); | ||
| const text = await res.text(); | ||
| let data; | ||
| try { data = text ? JSON.parse(text) : {}; } catch { data = { raw: text }; } | ||
| if (!res.ok) { | ||
| throw new Error(`Reddit ${method} ${pathname}: HTTP ${res.status} - ${data.message || data.raw || text || 'unknown'}`); | ||
| } | ||
| return data; | ||
| } | ||
| // ---------- plan helpers (same shape as the sibling engines) ---------- | ||
| function loadPlan(planPath) { | ||
| const abs = path.resolve(planPath); | ||
| return { abs, plan: JSON.parse(fs.readFileSync(abs, 'utf8')) }; | ||
| } | ||
| const ENGINE_OWNED_FIELDS = ['fbPostId', 'fbReelId', 'igMediaId', 'liPostId', 'ytVideoId', 'xPostId', 'tgMessageId', 'dcMessageId', 'redditPostId', 'status', 'postedAt', 'attempts']; | ||
| async function withPlanLock(abs, fn) { | ||
| const lockDir = `${abs}.lock.d`; | ||
| for (let i = 0; ; i++) { | ||
| try { fs.mkdirSync(lockDir); break; } catch (err) { | ||
| if (err.code !== 'EEXIST') throw err; | ||
| let ageMs = 0; | ||
| try { ageMs = Date.now() - fs.statSync(lockDir).mtimeMs; } catch { continue; } | ||
| if (ageMs > 15 * 60 * 1000) { try { fs.rmdirSync(lockDir); } catch { /* racing steal */ } continue; } | ||
| if (i >= 5) throw new Error(`plan lock busy: ${lockDir}`); | ||
| await new Promise((r) => setTimeout(r, 200)); | ||
| } | ||
| } | ||
| try { return fn(); } finally { try { fs.rmdirSync(lockDir); } catch { /* released */ } } | ||
| } | ||
| async function savePlan(abs, plan, touchedIds = null) { | ||
| await withPlanLock(abs, () => { | ||
| let out = plan; | ||
| if (Array.isArray(touchedIds)) { | ||
| try { | ||
| const disk = JSON.parse(fs.readFileSync(abs, 'utf8')); | ||
| for (const id of touchedIds) { | ||
| const mem = (plan.posts || []).find((p) => p.id === id); | ||
| const target = (disk.posts || []).find((p) => p.id === id); | ||
| if (!mem || !target) continue; | ||
| for (const f of ENGINE_OWNED_FIELDS) if (mem[f] !== undefined) target[f] = mem[f]; | ||
| } | ||
| out = disk; | ||
| } catch { /* unreadable disk copy - fall back to in-memory plan */ } | ||
| } | ||
| const tmp = `${abs}.tmp-${process.pid}`; | ||
| fs.writeFileSync(tmp, `${JSON.stringify(out, null, 2)}\n`); | ||
| fs.renameSync(tmp, abs); | ||
| }); | ||
| } | ||
| function appendAttempt(post, entry) { | ||
| post.attempts = Array.isArray(post.attempts) ? post.attempts : []; | ||
| post.attempts.push(entry); | ||
| } | ||
| const RUN = { results: [] }; | ||
| let JSON_MODE = false; | ||
| let ACTOR = 'cli'; | ||
| function resolveMediaPath(plan, post) { | ||
| const root = process.env.PENDPOST_ROOT ? path.resolve(process.env.PENDPOST_ROOT) : path.resolve(__dirname, '..'); | ||
| if (post.path) { | ||
| const abs = path.isAbsolute(post.path) ? post.path : path.resolve(root, post.path); | ||
| if (fs.existsSync(abs)) return abs; | ||
| } | ||
| if (post.file) { | ||
| const rel = path.join(plan.folder || '', post.file); | ||
| const abs = path.isAbsolute(rel) ? rel : path.resolve(root, rel); | ||
| if (fs.existsSync(abs)) return abs; | ||
| } | ||
| return null; | ||
| } | ||
| const isReddit = (post) => (post.platforms || []).includes('reddit'); | ||
| const bodyText = (post) => (post.redditText || post.caption || '').trim(); | ||
| // Title: explicit per-platform/post title, else the first non-empty caption line. | ||
| function titleFor(post) { | ||
| if (post.title && String(post.title).trim()) return String(post.title).trim().slice(0, TITLE_LIMIT); | ||
| const firstLine = (post.caption || '').split('\n').map((l) => l.trim()).find(Boolean) || ''; | ||
| return firstLine.slice(0, TITLE_LIMIT); | ||
| } | ||
| // A public/external media URL turns the submission into a `link` post; otherwise | ||
| // it is a `self` (text) post. We never host media for Reddit (no upload layer). | ||
| function externalUrl(post) { | ||
| const u = post.redditUrl || post.externalUrl || post.url || post.link || ''; | ||
| return typeof u === 'string' && /^https?:\/\//i.test(u.trim()) ? u.trim() : null; | ||
| } | ||
| // Reddit fullnames look like t3_abc; bare ids are the part after the underscore. | ||
| function bareId(name) { | ||
| if (!name) return null; | ||
| const s = String(name); | ||
| return s.includes('_') ? s.split('_').pop() : s; | ||
| } | ||
| function permalinkFor(post) { | ||
| if (!post.redditPostId) return null; | ||
| if (post.redditPermalink) return `https://www.reddit.com${post.redditPermalink}`; | ||
| const id = bareId(post.redditPostId); | ||
| return id ? `https://redd.it/${id}` : null; | ||
| } | ||
| // ---------- commands ---------- | ||
| async function cmdAuth() { | ||
| const token = await mintToken(); | ||
| const me = await reddit(token, 'GET', '/api/v1/me'); | ||
| console.log(`[ok] Reddit credentials valid - authenticated as u/${me.name}.`); | ||
| const sr = subreddit(); | ||
| if (!sr) console.log('[warn] REDDIT_SUBREDDIT is not set - publish-due will have no destination.'); | ||
| else console.log(`[ok] Destination subreddit: r/${sr}. Confirm the account may submit there and the subreddit allows bot posts.`); | ||
| RUN.results.push({ platform: 'reddit', action: 'auth', ok: true, detail: `u/${me.name}${sr ? ` -> r/${sr}` : ''}` }); | ||
| } | ||
| async function cmdRefresh() { | ||
| console.log('[info] Reddit tokens are minted per run from the password grant (no persisted refresh token).'); | ||
| } | ||
| async function cmdValidate(args) { | ||
| const { plan } = loadPlan(args.plan); | ||
| console.log('================ VALIDATION ONLY - NOTHING WILL BE PUBLISHED ================'); | ||
| let token = null; | ||
| try { | ||
| token = await mintToken(); | ||
| const me = await reddit(token, 'GET', '/api/v1/me'); | ||
| console.log(`[ok] Credentials valid - authenticated as u/${me.name}.`); | ||
| } catch (err) { | ||
| console.log(`[warn] auth check failed (${err.message}). Continuing to content preview.`); | ||
| } | ||
| const sr = subreddit(); | ||
| console.log(`[info] Destination subreddit: ${sr ? `r/${sr}` : '(REDDIT_SUBREDDIT not set)'}`); | ||
| const targets = (plan.posts || []).filter((p) => isReddit(p) && (!args.only || p.id === args.only)); | ||
| if (!targets.length) { console.log('[warn] No Reddit entries match.'); return; } | ||
| for (const post of targets) { | ||
| const title = titleFor(post); | ||
| const ext = externalUrl(post); | ||
| const text = bodyText(post); | ||
| console.log(`\n----- ${post.id} -----`); | ||
| console.log(`[preview] kind: ${ext ? 'link' : 'self'}`); | ||
| console.log(`[preview] title (${title.length}/${TITLE_LIMIT}${title.length > TITLE_LIMIT ? ' - OVER LIMIT' : ''}): ${title || '(MISSING - required)'}`); | ||
| if (ext) { | ||
| console.log(`[preview] url: ${ext}`); | ||
| } else { | ||
| console.log(`[preview] text (${text.length}/${TEXT_LIMIT}${text.length > TEXT_LIMIT ? ' - OVER LIMIT' : ''}):`); | ||
| console.log(text); | ||
| } | ||
| if (post.type !== 'text' && !ext) { | ||
| const mediaPath = resolveMediaPath(plan, post); | ||
| if (mediaPath) console.log(`[note] local media ${path.basename(mediaPath)} present, but Reddit has no upload layer here - set a public redditUrl for a link post.`); | ||
| } | ||
| } | ||
| console.log('\n================ VALIDATION COMPLETE ================'); | ||
| } | ||
| async function cmdPublishDue(args) { | ||
| const { abs, plan } = loadPlan(args.plan); | ||
| const sr = subreddit(); | ||
| if (!sr) throw new Error('REDDIT_SUBREDDIT is not set - cannot publish.'); | ||
| const now = Date.now(); | ||
| let published = 0; | ||
| let token = null; | ||
| for (const post of plan.posts || []) { | ||
| if (args.only && post.id !== args.only) continue; | ||
| if (!isReddit(post)) continue; | ||
| if (post.executionMode !== 'fully-scheduled') continue; | ||
| if (post.status !== 'planned') continue; | ||
| if ((post.approval || 'draft') !== 'approved') { | ||
| console.log(`[skip] ${post.id}: approval is "${post.approval || 'draft'}" - only approved posts publish.`); | ||
| continue; | ||
| } | ||
| const dueMs = Date.parse(post.scheduledAt); | ||
| if (Number.isNaN(dueMs) || dueMs > now) continue; | ||
| const title = titleFor(post); | ||
| if (!title) { console.log(`[warn] ${post.id}: due but no title (post.title / first caption line) - skipping.`); continue; } | ||
| if (title.length > TITLE_LIMIT) { console.log(`[warn] ${post.id}: title is ${title.length} chars (> ${TITLE_LIMIT}) - skipping.`); continue; } | ||
| const ext = externalUrl(post); | ||
| const text = bodyText(post); | ||
| if (!ext && text.length > TEXT_LIMIT) { console.log(`[warn] ${post.id}: text is ${text.length} chars (> ${TEXT_LIMIT}) - skipping.`); continue; } | ||
| if (args['dry-run']) { | ||
| console.log(ext ? `[dry] ${post.id}: would submit a link post -> ${ext}` : `[dry] ${post.id}: would submit a self (text) post (${text.length} chars).`); | ||
| continue; | ||
| } | ||
| if (!token) { | ||
| try { token = await mintToken(); } catch (err) { throw new Error(`cannot mint Reddit token: ${err.message}`); } | ||
| } | ||
| console.log(`[info] ${post.id}: submitting ${ext ? 'link' : 'self'} post to r/${sr}...`); | ||
| try { | ||
| const form = ext | ||
| ? { api_type: 'json', sr, title, kind: 'link', url: ext } | ||
| : { api_type: 'json', sr, title, kind: 'self', text }; | ||
| const data = await reddit(token, 'POST', '/api/submit', { form }); | ||
| const errs = data?.json?.errors; | ||
| if (Array.isArray(errs) && errs.length) { | ||
| throw new Error(`submit rejected: ${errs.map((e) => (Array.isArray(e) ? e.join(' ') : String(e))).join('; ').slice(0, 200)}`); | ||
| } | ||
| const d = data?.json?.data || {}; | ||
| const name = d.name || (d.id ? `t3_${bareId(d.id)}` : null); | ||
| if (!name) throw new Error(`submit returned no id: ${JSON.stringify(data).slice(0, 200)}`); | ||
| post.redditPostId = String(name); | ||
| if (typeof d.url === 'string') post.redditPermalink = d.url.replace(/^https?:\/\/(www\.)?reddit\.com/i, '') || post.redditPermalink; | ||
| post.status = 'posted'; | ||
| post.postedAt = new Date(now).toISOString(); | ||
| appendAttempt(post, { ts: new Date().toISOString(), platform: 'reddit', action: 'publish', ok: true, errorCode: null, errorMessage: null, actor: ACTOR }); | ||
| await savePlan(abs, plan, [post.id]); | ||
| RUN.results.push({ postId: post.id, platform: 'reddit', action: 'publish', ok: true, id: String(name) }); | ||
| console.log(`[ok] ${post.id}: submitted to r/${sr} (${name}).`); | ||
| published += 1; | ||
| } catch (err) { | ||
| appendAttempt(post, { ts: new Date().toISOString(), platform: 'reddit', action: 'publish', ok: false, errorCode: 'engine_failure', errorMessage: err.message.slice(0, 300), actor: ACTOR }); | ||
| await savePlan(abs, plan, [post.id]); | ||
| RUN.results.push({ postId: post.id, platform: 'reddit', action: 'publish', ok: false, errorCode: 'engine_failure', errorMessage: err.message.slice(0, 300) }); | ||
| console.error(`[err] ${post.id}: Reddit submit failed - ${err.message}`); | ||
| continue; | ||
| } | ||
| } | ||
| console.log(`[done] publish-due complete - ${published} submission(s) published.`); | ||
| } | ||
| async function cmdStatus(args) { | ||
| const { plan } = loadPlan(args.plan); | ||
| console.log('[info] Reddit plan entries:'); | ||
| for (const post of (plan.posts || []).filter(isReddit)) { | ||
| console.log(` ${post.id.padEnd(18)} ${String(post.status).padEnd(10)} ${post.scheduledAt} mode=${post.executionMode}${post.redditPostId ? ` reddit=${post.redditPostId}` : ''}`); | ||
| } | ||
| } | ||
| // Best-effort liveness: GET /api/info?id=<fullname> and report whether the | ||
| // submission still exists + its public permalink. | ||
| async function cmdVerify(args) { | ||
| const { plan } = loadPlan(args.plan); | ||
| let token = null; | ||
| try { token = await mintToken(); } catch { /* surfaced per row */ } | ||
| for (const post of (plan.posts || []).filter(isReddit)) { | ||
| if (args.only && post.id !== args.only) continue; | ||
| if (!post.redditPostId) continue; | ||
| if (!token) { | ||
| RUN.results.push({ postId: post.id, platform: 'reddit', action: 'verify', ok: true, live: false, state: 'unknown', permalink: permalinkFor(post), id: post.redditPostId }); | ||
| continue; | ||
| } | ||
| try { | ||
| const data = await reddit(token, 'GET', `/api/info?id=${encodeURIComponent(post.redditPostId)}`); | ||
| const child = data?.data?.children?.[0]?.data; | ||
| const live = Boolean(child) && !child.removed && !child.removed_by_category; | ||
| const permalink = child?.permalink ? `https://www.reddit.com${child.permalink}` : permalinkFor(post); | ||
| RUN.results.push({ postId: post.id, platform: 'reddit', action: 'verify', ok: true, live, state: live ? 'submitted' : 'removed', permalink, id: post.redditPostId }); | ||
| } catch (err) { | ||
| RUN.results.push({ postId: post.id, platform: 'reddit', action: 'verify', ok: false, errorCode: 'engine_failure', errorMessage: String(err.message || err).slice(0, 200), id: post.redditPostId }); | ||
| } | ||
| } | ||
| } | ||
| // Reddit exposes only coarse public counters to a bot - honest, minimal metrics | ||
| // (score + comment count) pulled from /api/info, never engagement breakdowns. | ||
| async function cmdInsights(args) { | ||
| const { plan } = loadPlan(args.plan); | ||
| let token = null; | ||
| try { token = await mintToken(); } catch { console.log('[info] Reddit insights: could not authenticate - skipping.'); return; } | ||
| for (const post of (plan.posts || []).filter(isReddit)) { | ||
| if (args.only && post.id !== args.only) continue; | ||
| if (!post.redditPostId) continue; | ||
| try { | ||
| const data = await reddit(token, 'GET', `/api/info?id=${encodeURIComponent(post.redditPostId)}`); | ||
| const child = data?.data?.children?.[0]?.data; | ||
| if (!child) { RUN.results.push({ postId: post.id, platform: 'reddit', action: 'insights', ok: true, metrics: {} }); continue; } | ||
| RUN.results.push({ postId: post.id, platform: 'reddit', action: 'insights', ok: true, metrics: { score: child.score ?? 0, num_comments: child.num_comments ?? 0, upvote_ratio: child.upvote_ratio ?? null } }); | ||
| } catch (err) { | ||
| RUN.results.push({ postId: post.id, platform: 'reddit', action: 'insights', ok: false, errorCode: 'engine_failure', errorMessage: String(err.message || err).slice(0, 200) }); | ||
| } | ||
| } | ||
| } | ||
| async function cmdDelete(args) { | ||
| if (!args.id) { console.error('[err] delete requires --id <fullname> (e.g. t3_abc123)'); process.exit(2); } | ||
| const token = await mintToken(); | ||
| await reddit(token, 'POST', '/api/del', { form: { id: String(args.id) } }); | ||
| RUN.results.push({ platform: 'reddit', action: 'delete', ok: true, id: String(args.id) }); | ||
| console.log(`[ok] deleted Reddit submission ${args.id}.`); | ||
| } | ||
| async function cmdProbe() { | ||
| if (!readEnv('REDDIT_CLIENT_ID') || !readEnv('REDDIT_USERNAME')) { | ||
| RUN.results.push({ platform: 'reddit', action: 'probe', ok: false, detail: 'not configured (REDDIT_CLIENT_ID / REDDIT_USERNAME missing)' }); | ||
| return; | ||
| } | ||
| try { | ||
| const token = await mintToken(); | ||
| const me = await reddit(token, 'GET', '/api/v1/me'); | ||
| RUN.results.push({ platform: 'reddit', action: 'probe', ok: true, detail: `connected as u/${me.name}`, tokenExpiresAt: null }); | ||
| } catch (err) { | ||
| RUN.results.push({ platform: 'reddit', action: 'probe', ok: false, detail: String(err.message || err).slice(0, 200) }); | ||
| } | ||
| } | ||
| // ---------- main ---------- | ||
| function parseArgs(argv) { | ||
| const args = { _: [] }; | ||
| for (let i = 2; i < argv.length; i++) { | ||
| const a = argv[i]; | ||
| if (a.startsWith('--')) { | ||
| const key = a.slice(2); | ||
| const next = argv[i + 1]; | ||
| if (next === undefined || next.startsWith('--')) args[key] = true; | ||
| else args[key] = argv[++i]; | ||
| } else args._.push(a); | ||
| } | ||
| return args; | ||
| } | ||
| const COMMANDS = { | ||
| auth: cmdAuth, | ||
| connect: cmdAuth, | ||
| refresh: cmdRefresh, | ||
| validate: cmdValidate, | ||
| 'publish-due': cmdPublishDue, | ||
| status: cmdStatus, | ||
| verify: cmdVerify, | ||
| insights: cmdInsights, | ||
| delete: cmdDelete, | ||
| probe: cmdProbe, | ||
| }; | ||
| async function main() { | ||
| const args = parseArgs(process.argv); | ||
| JSON_MODE = Boolean(args.json); | ||
| ACTOR = typeof args.actor === 'string' ? args.actor : 'cli'; | ||
| if (JSON_MODE) console.log = (...a) => console.error(...a); | ||
| const commandName = args._[0]; | ||
| if (resolveMode('reddit') === 'mock' && isMockableCommand(commandName)) { | ||
| const envelope = await runMockCommand({ | ||
| platform: 'reddit', command: commandName, | ||
| planPath: typeof args.plan === 'string' ? path.resolve(String(args.plan)) : null, | ||
| only: typeof args.only === 'string' ? args.only : null, | ||
| }); | ||
| if (JSON_MODE) process.stdout.write(`${JSON.stringify(envelope)}\n`); | ||
| else console.error(`[mock] reddit ${commandName}: ${envelope.results.length} result(s)`); | ||
| return; | ||
| } | ||
| const cmd = COMMANDS[commandName]; | ||
| if (!cmd) { | ||
| console.error(`Usage: node scripts/reddit-social.mjs <${Object.keys(COMMANDS).join('|')}> [options]`); | ||
| process.exit(2); | ||
| } | ||
| if (['validate', 'publish-due', 'status', 'verify', 'insights'].includes(commandName) && !args.plan) { | ||
| console.error(`[err] ${commandName} requires --plan <post-plan.json>`); | ||
| process.exit(2); | ||
| } | ||
| await cmd(args); | ||
| if (JSON_MODE) process.stdout.write(`${JSON.stringify({ ok: true, ...RUN })}\n`); | ||
| } | ||
| main().catch(async (err) => { | ||
| console.error('[err]', err.message || err); | ||
| if (JSON_MODE) process.stdout.write(`${JSON.stringify({ ok: false, error: String(err.message || err).slice(0, 300), ...RUN })}\n`); | ||
| process.exit(1); | ||
| }); |
| #!/usr/bin/env node | ||
| /** | ||
| * telegram-social.mjs - direct Telegram channel publishing via the Bot API. | ||
| * | ||
| * Sibling of scripts/x-social.mjs / linkedin-social.mjs / meta-social.mjs: | ||
| * the same zero-dep, plan-driven, publish-straight-from-the-local-render pattern, | ||
| * with Telegram's own (refreshingly simple) auth + posting model. | ||
| * | ||
| * Telegram has NO scheduling API and NO browser OAuth - a bot is a STATIC token | ||
| * from @BotFather, so entries publish at their due time by re-running `publish-due` | ||
| * (driven by the scheduler tick), exactly like Instagram / LinkedIn / X. | ||
| * | ||
| * AUTH - a single static bot token, no ceremony: | ||
| * TELEGRAM_BOT_TOKEN the token @BotFather hands you (e.g. 8751599818:AA...). | ||
| * TELEGRAM_CHANNEL_ID the destination: a public channel @username (recommended, | ||
| * gives clean permalinks) or a numeric chat id (-100...). | ||
| * The bot must be an ADMINISTRATOR of the channel with "Post Messages" rights. | ||
| * `connect`/`auth` here is just a validation handshake (getMe + getChat): there is | ||
| * no token to mint, so it only confirms the static creds actually authenticate. | ||
| * | ||
| * Media uploads stream straight from the local render folder (post.path / | ||
| * plan.folder + post.file) as a Bot API multipart upload - no hosting layer. | ||
| * Text comes from post.tgCaption (falls back to post.caption), the additive | ||
| * per-platform override pattern x uses for xCaption. | ||
| * | ||
| * Commands: | ||
| * auth | connect validate the static creds (getMe + getChat); writes nothing | ||
| * refresh no-op (bot tokens are static) - kept for sibling parity | ||
| * validate --plan <p> [--only <id>] side-effect-free preview, never posts | ||
| * publish-due --plan <p> [--only <id>] [--dry-run] publish any due Telegram entry | ||
| * status --plan <p> list Telegram plan entries | ||
| * verify --plan <p> [--only <id>] read-only liveness (best-effort) | ||
| * insights --plan <p> [--only <id>] no-op (Bot API exposes no per-post metrics) | ||
| * probe read-only health probe (getMe) | ||
| * delete --id <messageId> delete a channel message (cleanup) | ||
| */ | ||
| import fs from 'node:fs'; | ||
| import path from 'node:path'; | ||
| import { fileURLToPath } from 'node:url'; | ||
| import { resolveMode, isMockableCommand } from '../lib/mode.mjs'; | ||
| import { runMockCommand } from '../lib/drivers/mock-driver.mjs'; | ||
| import { envPath } from '../lib/util.mjs'; | ||
| const __dirname = path.dirname(fileURLToPath(import.meta.url)); | ||
| const ENV_PATH = envPath(); | ||
| // Telegram caps: a text message at 4096 chars, a media caption at 1024. | ||
| const TEXT_LIMIT = 4096; | ||
| const CAPTION_LIMIT = 1024; | ||
| // ---------- env helpers (same shape as the sibling engines) ---------- | ||
| function readEnvRaw() { | ||
| return fs.existsSync(ENV_PATH) ? fs.readFileSync(ENV_PATH, 'utf8') : ''; | ||
| } | ||
| function readEnv(name) { | ||
| const m = readEnvRaw().match(new RegExp(`^${name}=(.+)$`, 'm')); | ||
| return m ? m[1].trim() : null; | ||
| } | ||
| const apiBase = () => `https://api.telegram.org/bot${readEnv('TELEGRAM_BOT_TOKEN')}`; | ||
| const channelId = () => readEnv('TELEGRAM_CHANNEL_ID'); | ||
| // ---------- Bot API helper ---------- | ||
| async function tg(method, { body, form } = {}) { | ||
| const url = `${apiBase()}/${method}`; | ||
| const init = form | ||
| ? { method: 'POST', body: form } | ||
| : { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body || {}) }; | ||
| const res = await fetch(url, init); | ||
| const text = await res.text(); | ||
| let data; | ||
| try { data = text ? JSON.parse(text) : {}; } catch { data = { raw: text }; } | ||
| if (!res.ok || data.ok === false) { | ||
| throw new Error(`Telegram ${method}: HTTP ${res.status} - ${data.description || data.raw || text || 'unknown'}`); | ||
| } | ||
| return data.result; | ||
| } | ||
| // ---------- plan helpers (same shape as the sibling engines) ---------- | ||
| function loadPlan(planPath) { | ||
| const abs = path.resolve(planPath); | ||
| return { abs, plan: JSON.parse(fs.readFileSync(abs, 'utf8')) }; | ||
| } | ||
| const ENGINE_OWNED_FIELDS = ['fbPostId', 'fbReelId', 'igMediaId', 'liPostId', 'ytVideoId', 'xPostId', 'tgMessageId', 'dcMessageId', 'status', 'postedAt', 'attempts']; | ||
| async function withPlanLock(abs, fn) { | ||
| const lockDir = `${abs}.lock.d`; | ||
| for (let i = 0; ; i++) { | ||
| try { fs.mkdirSync(lockDir); break; } catch (err) { | ||
| if (err.code !== 'EEXIST') throw err; | ||
| let ageMs = 0; | ||
| try { ageMs = Date.now() - fs.statSync(lockDir).mtimeMs; } catch { continue; } | ||
| if (ageMs > 15 * 60 * 1000) { try { fs.rmdirSync(lockDir); } catch { /* racing steal */ } continue; } | ||
| if (i >= 5) throw new Error(`plan lock busy: ${lockDir}`); | ||
| await new Promise((r) => setTimeout(r, 200)); | ||
| } | ||
| } | ||
| try { return fn(); } finally { try { fs.rmdirSync(lockDir); } catch { /* released */ } } | ||
| } | ||
| async function savePlan(abs, plan, touchedIds = null) { | ||
| await withPlanLock(abs, () => { | ||
| let out = plan; | ||
| if (Array.isArray(touchedIds)) { | ||
| try { | ||
| const disk = JSON.parse(fs.readFileSync(abs, 'utf8')); | ||
| for (const id of touchedIds) { | ||
| const mem = (plan.posts || []).find((p) => p.id === id); | ||
| const target = (disk.posts || []).find((p) => p.id === id); | ||
| if (!mem || !target) continue; | ||
| for (const f of ENGINE_OWNED_FIELDS) if (mem[f] !== undefined) target[f] = mem[f]; | ||
| } | ||
| out = disk; | ||
| } catch { /* unreadable disk copy - fall back to in-memory plan */ } | ||
| } | ||
| const tmp = `${abs}.tmp-${process.pid}`; | ||
| fs.writeFileSync(tmp, `${JSON.stringify(out, null, 2)}\n`); | ||
| fs.renameSync(tmp, abs); | ||
| }); | ||
| } | ||
| function appendAttempt(post, entry) { | ||
| post.attempts = Array.isArray(post.attempts) ? post.attempts : []; | ||
| post.attempts.push(entry); | ||
| } | ||
| const RUN = { results: [] }; | ||
| let JSON_MODE = false; | ||
| let ACTOR = 'cli'; | ||
| function resolveMediaPath(plan, post) { | ||
| const root = process.env.PENDPOST_ROOT ? path.resolve(process.env.PENDPOST_ROOT) : path.resolve(__dirname, '..'); | ||
| if (post.path) { | ||
| const abs = path.isAbsolute(post.path) ? post.path : path.resolve(root, post.path); | ||
| if (fs.existsSync(abs)) return abs; | ||
| } | ||
| if (post.file) { | ||
| const rel = path.join(plan.folder || '', post.file); | ||
| const abs = path.isAbsolute(rel) ? rel : path.resolve(root, rel); | ||
| if (fs.existsSync(abs)) return abs; | ||
| } | ||
| return null; | ||
| } | ||
| const isTelegram = (post) => (post.platforms || []).includes('telegram'); | ||
| const isTextPost = (post) => post.type === 'text'; | ||
| const messageText = (post) => (post.tgCaption || post.caption || '').trim(); | ||
| // Public permalink: only derivable for a public @username channel. | ||
| function permalinkFor(post) { | ||
| if (!post.tgMessageId) return null; | ||
| const ch = (channelId() || '').trim(); | ||
| if (ch.startsWith('@')) return `https://t.me/${ch.slice(1)}/${post.tgMessageId}`; | ||
| return null; | ||
| } | ||
| function mediaField(localPath) { | ||
| return /\.(mp4|mov|m4v)$/i.test(localPath) ? { field: 'video', method: 'sendVideo' } : { field: 'photo', method: 'sendPhoto' }; | ||
| } | ||
| // ---------- commands ---------- | ||
| async function cmdAuth() { | ||
| if (!readEnv('TELEGRAM_BOT_TOKEN')) { console.error('[err] TELEGRAM_BOT_TOKEN missing in .env (get it from @BotFather).'); process.exit(2); } | ||
| const me = await tg('getMe'); | ||
| console.log(`[ok] Bot token valid - authenticated as @${me.username} (id ${me.id}).`); | ||
| const ch = channelId(); | ||
| if (!ch) { console.error('[err] TELEGRAM_CHANNEL_ID missing in .env (the @username or numeric id of the destination channel).'); process.exit(2); } | ||
| const chat = await tg('getChat', { body: { chat_id: ch } }); | ||
| console.log(`[ok] Channel reachable - ${chat.type} "${chat.title || ch}". Ensure the bot is an admin with Post Messages.`); | ||
| RUN.results.push({ platform: 'telegram', action: 'auth', ok: true, detail: `@${me.username} -> ${chat.title || ch}` }); | ||
| } | ||
| async function cmdRefresh() { | ||
| console.log('[info] Telegram bot tokens are static (no refresh).'); | ||
| } | ||
| async function cmdValidate(args) { | ||
| const { plan } = loadPlan(args.plan); | ||
| console.log('================ VALIDATION ONLY - NOTHING WILL BE PUBLISHED ================'); | ||
| try { | ||
| const me = await tg('getMe'); | ||
| console.log(`[ok] Token valid - authenticated as @${me.username}.`); | ||
| } catch (err) { | ||
| console.log(`[warn] getMe failed (${err.message}). Continuing to caption preview.`); | ||
| } | ||
| const targets = (plan.posts || []).filter((p) => isTelegram(p) && (!args.only || p.id === args.only)); | ||
| if (!targets.length) { console.log('[warn] No Telegram entries match.'); return; } | ||
| for (const post of targets) { | ||
| const text = messageText(post); | ||
| const limit = isTextPost(post) ? TEXT_LIMIT : CAPTION_LIMIT; | ||
| console.log(`\n----- ${post.id} -----`); | ||
| console.log(`[preview] type: ${post.type}`); | ||
| console.log(`[preview] text (${text.length}/${limit}${text.length > limit ? ' - OVER LIMIT' : ''}):`); | ||
| console.log(text); | ||
| if (!isTextPost(post)) { | ||
| const mediaPath = resolveMediaPath(plan, post); | ||
| if (!mediaPath) console.log(`[warn] media not found (${post.path || post.file}).`); | ||
| else console.log(`[preview] media: ${path.basename(mediaPath)} (${(fs.statSync(mediaPath).size / 1e6).toFixed(1)} MB)`); | ||
| } | ||
| } | ||
| console.log('\n================ VALIDATION COMPLETE ================'); | ||
| } | ||
| async function cmdPublishDue(args) { | ||
| const { abs, plan } = loadPlan(args.plan); | ||
| const ch = channelId(); | ||
| if (!ch) throw new Error('TELEGRAM_CHANNEL_ID is not set - cannot publish.'); | ||
| const now = Date.now(); | ||
| let published = 0; | ||
| for (const post of plan.posts || []) { | ||
| if (args.only && post.id !== args.only) continue; | ||
| if (!isTelegram(post)) continue; | ||
| if (post.executionMode !== 'fully-scheduled') continue; | ||
| if (post.status !== 'planned') continue; | ||
| if ((post.approval || 'draft') !== 'approved') { | ||
| console.log(`[skip] ${post.id}: approval is "${post.approval || 'draft'}" - only approved posts publish.`); | ||
| continue; | ||
| } | ||
| const dueMs = Date.parse(post.scheduledAt); | ||
| if (Number.isNaN(dueMs) || dueMs > now) continue; | ||
| const text = messageText(post); | ||
| const textPost = isTextPost(post); | ||
| if (textPost && !text) { console.log(`[warn] ${post.id}: due but no text (tgCaption/caption) - skipping.`); continue; } | ||
| const limit = textPost ? TEXT_LIMIT : CAPTION_LIMIT; | ||
| if (text.length > limit) { console.log(`[warn] ${post.id}: text is ${text.length} chars (> ${limit}) - skipping.`); continue; } | ||
| let mediaPath = null; | ||
| if (!textPost) { | ||
| mediaPath = resolveMediaPath(plan, post); | ||
| if (!mediaPath) { console.log(`[warn] ${post.id}: due but local media not found (${post.path || post.file}) - skipping.`); continue; } | ||
| } | ||
| if (args['dry-run']) { | ||
| console.log(textPost ? `[dry] ${post.id}: would send a text message (${text.length} chars).` : `[dry] ${post.id}: would upload ${path.basename(mediaPath)} + caption.`); | ||
| continue; | ||
| } | ||
| console.log(`[info] ${post.id}: publishing ${textPost ? 'text message' : 'media'} to Telegram...`); | ||
| try { | ||
| let result; | ||
| if (textPost) { | ||
| result = await tg('sendMessage', { body: { chat_id: ch, text } }); | ||
| } else { | ||
| const { field, method } = mediaField(mediaPath); | ||
| const form = new FormData(); | ||
| form.append('chat_id', String(ch)); | ||
| if (text) form.append('caption', text); | ||
| form.append(field, new Blob([fs.readFileSync(mediaPath)]), path.basename(mediaPath)); | ||
| result = await tg(method, { form }); | ||
| } | ||
| const messageId = result?.message_id; | ||
| if (!messageId) throw new Error(`send returned no message_id: ${JSON.stringify(result).slice(0, 200)}`); | ||
| post.tgMessageId = String(messageId); | ||
| post.status = 'posted'; | ||
| post.postedAt = new Date(now).toISOString(); | ||
| appendAttempt(post, { ts: new Date().toISOString(), platform: 'telegram', action: 'publish', ok: true, errorCode: null, errorMessage: null, actor: ACTOR }); | ||
| await savePlan(abs, plan, [post.id]); | ||
| RUN.results.push({ postId: post.id, platform: 'telegram', action: 'publish', ok: true, id: String(messageId) }); | ||
| console.log(`[ok] ${post.id}: published on Telegram (message ${messageId}).`); | ||
| published += 1; | ||
| } catch (err) { | ||
| appendAttempt(post, { ts: new Date().toISOString(), platform: 'telegram', action: 'publish', ok: false, errorCode: 'engine_failure', errorMessage: err.message.slice(0, 300), actor: ACTOR }); | ||
| await savePlan(abs, plan, [post.id]); | ||
| RUN.results.push({ postId: post.id, platform: 'telegram', action: 'publish', ok: false, errorCode: 'engine_failure', errorMessage: err.message.slice(0, 300) }); | ||
| console.error(`[err] ${post.id}: Telegram publish failed - ${err.message}`); | ||
| continue; | ||
| } | ||
| } | ||
| console.log(`[done] publish-due complete - ${published} message(s) published.`); | ||
| } | ||
| async function cmdStatus(args) { | ||
| const { plan } = loadPlan(args.plan); | ||
| console.log('[info] Telegram plan entries:'); | ||
| for (const post of (plan.posts || []).filter(isTelegram)) { | ||
| console.log(` ${post.id.padEnd(18)} ${String(post.status).padEnd(10)} ${post.scheduledAt} mode=${post.executionMode}${post.tgMessageId ? ` tg=${post.tgMessageId}` : ''}`); | ||
| } | ||
| } | ||
| // Best-effort liveness: the Bot API cannot read an arbitrary channel message back, | ||
| // so a stored message id (the post succeeded) + a reachable channel is our signal. | ||
| async function cmdVerify(args) { | ||
| const { plan } = loadPlan(args.plan); | ||
| const ch = channelId(); | ||
| let reachable = false; | ||
| try { await tg('getChat', { body: { chat_id: ch } }); reachable = true; } catch { /* surfaced per row */ } | ||
| for (const post of (plan.posts || []).filter(isTelegram)) { | ||
| if (args.only && post.id !== args.only) continue; | ||
| if (!post.tgMessageId) continue; | ||
| RUN.results.push({ postId: post.id, platform: 'telegram', action: 'verify', ok: true, live: reachable, state: reachable ? 'sent' : 'unknown', permalink: reachable ? permalinkFor(post) : null, id: post.tgMessageId }); | ||
| } | ||
| } | ||
| // Telegram's Bot API exposes no per-post metrics to a bot - honest no-op. | ||
| async function cmdInsights(args) { | ||
| const { plan } = loadPlan(args.plan); | ||
| void plan; | ||
| console.log('[info] Telegram Bot API exposes no per-post metrics - insights is a no-op.'); | ||
| } | ||
| async function cmdDelete(args) { | ||
| if (!args.id) { console.error('[err] delete requires --id <messageId>'); process.exit(2); } | ||
| await tg('deleteMessage', { body: { chat_id: channelId(), message_id: Number(args.id) } }); | ||
| RUN.results.push({ platform: 'telegram', action: 'delete', ok: true, id: String(args.id) }); | ||
| console.log(`[ok] deleted Telegram message ${args.id}.`); | ||
| } | ||
| async function cmdProbe() { | ||
| if (!readEnv('TELEGRAM_BOT_TOKEN')) { | ||
| RUN.results.push({ platform: 'telegram', action: 'probe', ok: false, detail: 'not configured (TELEGRAM_BOT_TOKEN missing)' }); | ||
| return; | ||
| } | ||
| try { | ||
| const me = await tg('getMe'); | ||
| RUN.results.push({ platform: 'telegram', action: 'probe', ok: true, detail: `connected as @${me.username}`, tokenExpiresAt: null }); | ||
| } catch (err) { | ||
| RUN.results.push({ platform: 'telegram', action: 'probe', ok: false, detail: String(err.message || err).slice(0, 200) }); | ||
| } | ||
| } | ||
| // ---------- main ---------- | ||
| function parseArgs(argv) { | ||
| const args = { _: [] }; | ||
| for (let i = 2; i < argv.length; i++) { | ||
| const a = argv[i]; | ||
| if (a.startsWith('--')) { | ||
| const key = a.slice(2); | ||
| const next = argv[i + 1]; | ||
| if (next === undefined || next.startsWith('--')) args[key] = true; | ||
| else args[key] = argv[++i]; | ||
| } else args._.push(a); | ||
| } | ||
| return args; | ||
| } | ||
| const COMMANDS = { | ||
| auth: cmdAuth, | ||
| connect: cmdAuth, | ||
| refresh: cmdRefresh, | ||
| validate: cmdValidate, | ||
| 'publish-due': cmdPublishDue, | ||
| status: cmdStatus, | ||
| verify: cmdVerify, | ||
| insights: cmdInsights, | ||
| delete: cmdDelete, | ||
| probe: cmdProbe, | ||
| }; | ||
| async function main() { | ||
| const args = parseArgs(process.argv); | ||
| JSON_MODE = Boolean(args.json); | ||
| ACTOR = typeof args.actor === 'string' ? args.actor : 'cli'; | ||
| if (JSON_MODE) console.log = (...a) => console.error(...a); | ||
| const commandName = args._[0]; | ||
| if (resolveMode('telegram') === 'mock' && isMockableCommand(commandName)) { | ||
| const envelope = await runMockCommand({ | ||
| platform: 'telegram', command: commandName, | ||
| planPath: typeof args.plan === 'string' ? path.resolve(String(args.plan)) : null, | ||
| only: typeof args.only === 'string' ? args.only : null, | ||
| }); | ||
| if (JSON_MODE) process.stdout.write(`${JSON.stringify(envelope)}\n`); | ||
| else console.error(`[mock] telegram ${commandName}: ${envelope.results.length} result(s)`); | ||
| return; | ||
| } | ||
| const cmd = COMMANDS[commandName]; | ||
| if (!cmd) { | ||
| console.error(`Usage: node scripts/telegram-social.mjs <${Object.keys(COMMANDS).join('|')}> [options]`); | ||
| process.exit(2); | ||
| } | ||
| if (['validate', 'publish-due', 'status', 'verify', 'insights'].includes(commandName) && !args.plan) { | ||
| console.error(`[err] ${commandName} requires --plan <post-plan.json>`); | ||
| process.exit(2); | ||
| } | ||
| await cmd(args); | ||
| if (JSON_MODE) process.stdout.write(`${JSON.stringify({ ok: true, ...RUN })}\n`); | ||
| } | ||
| main().catch(async (err) => { | ||
| console.error('[err]', err.message || err); | ||
| if (JSON_MODE) process.stdout.write(`${JSON.stringify({ ok: false, error: String(err.message || err).slice(0, 300), ...RUN })}\n`); | ||
| process.exit(1); | ||
| }); |
| #!/usr/bin/env node | ||
| /** | ||
| * tiktok-social.mjs - direct TikTok publishing via the Content Posting API. | ||
| * | ||
| * Sibling of scripts/telegram-social.mjs / x-social.mjs / yt-social.mjs: the same | ||
| * zero-dep, plan-driven, publish-straight-from-the-local-render pattern, with | ||
| * TikTok's OAuth2 (Login Kit) auth and its multi-step direct-post upload flow. | ||
| * | ||
| * THIS IS THE RISKIEST LANE. TikTok publishing is a THREE-step dance with no | ||
| * single "post" call: INIT (reserve a publish_id + upload_url) -> UPLOAD (PUT the | ||
| * raw bytes) -> POLL (status/fetch until PUBLISH_COMPLETE | FAILED). Every step | ||
| * can return a partial / shape-shifted payload, so this engine is defensive about | ||
| * missing fields everywhere and bounds the poll loop. | ||
| * | ||
| * ============================================================================ | ||
| * HONESTY / BETA: unaudited apps can ONLY post privately (SELF_ONLY). | ||
| * ============================================================================ | ||
| * TikTok's Content Posting API gates PUBLIC posting behind a per-app content | ||
| * audit (typically a 2-6 week review). Until your TikTok app passes that audit it | ||
| * is "unaudited": every post is forced to privacy_level SELF_ONLY (visible only | ||
| * to the posting account) and is rate-limited to a handful of posts per 24h for a | ||
| * small set of explicitly-allowlisted target users. We therefore DEFAULT | ||
| * privacy_level to 'SELF_ONLY' (see PRIVACY_LEVEL below) and never silently | ||
| * publish public content. Treat this lane as BETA until your app is audited; flip | ||
| * to a public privacy level (e.g. via post.ttPrivacy) only once TikTok has | ||
| * approved your app for public posting. | ||
| * ============================================================================ | ||
| * | ||
| * TikTok has NO native scheduling API, so entries publish at their due time by | ||
| * re-running `publish-due` (driven by the scheduler tick), exactly like Telegram | ||
| * / Instagram / X. `schedule` is intentionally unsupported. | ||
| * | ||
| * TikTok requires MEDIA: this lane publishes VIDEO posts only (a video render in | ||
| * the local plan folder). Text-only posts are not a TikTok concept and are skipped. | ||
| * | ||
| * AUTH - TikTok Login Kit OAuth2 (loopback authorize-code + refresh): | ||
| * TIKTOK_CLIENT_KEY the app's client key (TikTok for Developers console) | ||
| * TIKTOK_CLIENT_SECRET the app's client secret | ||
| * Persisted after `auth`: | ||
| * TIKTOK_ACCESS_TOKEN short-lived (~24h) bearer token | ||
| * TIKTOK_REFRESH_TOKEN long-lived (~365d) refresh token | ||
| * TIKTOK_TOKEN_EXPIRES_AT epoch ms when the access token expires | ||
| * Redirect: http://127.0.0.1:8088/oauth/tiktok/callback (loopback, like yt-social). | ||
| * | ||
| * Commands: | ||
| * auth | connect one-time loopback OAuth ceremony; writes client + tokens | ||
| * refresh mint a fresh access token from the refresh token | ||
| * validate --plan <p> [--only <id>] side-effect-free preview, never posts | ||
| * publish-due --plan <p> [--only <id>] [--dry-run] publish any due TikTok video | ||
| * status --plan <p> list TikTok plan entries | ||
| * verify --plan <p> [--only <id>] read-only liveness (best-effort) | ||
| * insights --plan <p> [--only <id>] honest no-op (no per-post metrics here) | ||
| * delete --id <publishId> no-op (the API cannot delete a post) | ||
| * probe read-only health probe (user/info) | ||
| */ | ||
| import fs from 'node:fs'; | ||
| import path from 'node:path'; | ||
| import http from 'node:http'; | ||
| import crypto from 'node:crypto'; | ||
| import { execFile } from 'node:child_process'; | ||
| import { fileURLToPath } from 'node:url'; | ||
| import { resolveMode, isMockableCommand } from '../lib/mode.mjs'; | ||
| import { runMockCommand } from '../lib/drivers/mock-driver.mjs'; | ||
| import { envPath } from '../lib/util.mjs'; | ||
| const __dirname = path.dirname(fileURLToPath(import.meta.url)); | ||
| const ENV_PATH = envPath(); | ||
| // ---------- TikTok endpoints ---------- | ||
| const AUTH_URL = 'https://www.tiktok.com/v2/auth/authorize/'; | ||
| const TOKEN_URL = 'https://open.tiktokapis.com/v2/oauth/token/'; | ||
| const INIT_URL = 'https://open.tiktokapis.com/v2/post/publish/video/init/'; | ||
| const STATUS_URL = 'https://open.tiktokapis.com/v2/post/publish/status/fetch/'; | ||
| // user/info REQUIRES an explicit ?fields= list, or it 400s with invalid_params. | ||
| const USERINFO_URL = 'https://open.tiktokapis.com/v2/user/info/?fields=open_id,union_id,display_name'; | ||
| // video.publish + video.upload are the Content Posting API scopes; user.info.basic | ||
| // powers `probe`. Space-separated per the OAuth2 spec; comma-joined for TikTok's | ||
| // authorize endpoint (it expects a comma list there). | ||
| const SCOPES = ['user.info.basic', 'video.upload', 'video.publish']; | ||
| const DEFAULT_REDIRECT = 'http://127.0.0.1:8088/oauth/tiktok/callback'; | ||
| // PRIVACY: unaudited apps cannot post public. We DEFAULT to SELF_ONLY (private to | ||
| // the posting account); a post may request a wider level via post.ttPrivacy, but | ||
| // TikTok enforces the audit requirement server-side - a wider level on an un-audited | ||
| // app is rejected at publish, the engine does not gate it. Do NOT change this default. | ||
| const PRIVACY_LEVEL = 'SELF_ONLY'; | ||
| // Caption cap: TikTok titles/descriptions accept up to 2200 chars. | ||
| const CAPTION_LIMIT = 2200; | ||
| // Status-poll bounds: TikTok processing is async; poll with linear backoff. | ||
| const POLL_MAX_TRIES = 10; | ||
| const POLL_BASE_MS = 3000; | ||
| // Refresh the access token when it expires within this window. | ||
| const REFRESH_BUFFER_MS = 5 * 60 * 1000; | ||
| const redirectUri = () => readEnv('TIKTOK_REDIRECT_URI') || DEFAULT_REDIRECT; | ||
| // ---------- env helpers (same shape as the sibling engines) ---------- | ||
| function readEnvRaw() { | ||
| return fs.existsSync(ENV_PATH) ? fs.readFileSync(ENV_PATH, 'utf8') : ''; | ||
| } | ||
| function readEnv(name) { | ||
| const m = readEnvRaw().match(new RegExp(`^${name}=(.+)$`, 'm')); | ||
| return m ? m[1].trim() : null; | ||
| } | ||
| function writeEnv(vars) { | ||
| let raw = readEnvRaw(); | ||
| for (const [k, v] of Object.entries(vars)) { | ||
| if (v == null) continue; | ||
| // function replacer: token values may contain '$', special in a string replace. | ||
| if (new RegExp(`^${k}=`, 'm').test(raw)) { | ||
| raw = raw.replace(new RegExp(`^${k}=.*$`, 'm'), () => `${k}=${v}`); | ||
| } else { | ||
| raw += `${raw.endsWith('\n') || raw === '' ? '' : '\n'}${k}=${v}\n`; | ||
| } | ||
| } | ||
| const tmp = `${ENV_PATH}.tmp-${process.pid}`; | ||
| fs.writeFileSync(tmp, raw, { mode: 0o600 }); | ||
| fs.renameSync(tmp, ENV_PATH); | ||
| } | ||
| function requireEnv(name) { | ||
| const v = readEnv(name); | ||
| if (!v) { | ||
| console.error(`[err] ${name} missing in .env - run 'node scripts/tiktok-social.mjs auth' first.`); | ||
| process.exit(1); | ||
| } | ||
| return v; | ||
| } | ||
| function tokenTail(t) { | ||
| return t ? `...${t.slice(-6)}, length ${t.length}` : '(none)'; | ||
| } | ||
| // ---------- oauth ---------- | ||
| // TikTok's token endpoint is x-www-form-urlencoded; errors arrive as either a | ||
| // flat {error, error_description} or nested {error:{code,message}} - handle both. | ||
| async function tokenExchange(params) { | ||
| const res = await fetch(TOKEN_URL, { | ||
| method: 'POST', | ||
| headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Cache-Control': 'no-cache' }, | ||
| body: new URLSearchParams(params), | ||
| }); | ||
| const text = await res.text(); | ||
| let data; | ||
| try { data = text ? JSON.parse(text) : {}; } catch { data = { raw: text }; } | ||
| const errCode = typeof data.error === 'object' ? data.error?.code : data.error; | ||
| const errMsg = typeof data.error === 'object' | ||
| ? data.error?.message | ||
| : (data.error_description || data.raw || text); | ||
| if (!res.ok || (errCode && errCode !== 'ok')) { | ||
| const hint = errCode === 'invalid_grant' | ||
| ? " - the refresh token is expired/revoked. Re-run 'node scripts/tiktok-social.mjs auth'." | ||
| : ''; | ||
| throw new Error(`TikTok OAuth ${params.grant_type}: HTTP ${res.status} ${errCode || ''} - ${errMsg || 'unknown'}${hint}`); | ||
| } | ||
| return data; | ||
| } | ||
| // Persist the token triple from a token response, defensively (the response may | ||
| // nest fields or omit a refresh token on a refresh-grant that rotates lazily). | ||
| function persistTokens(data) { | ||
| const access = data.access_token; | ||
| const refresh = data.refresh_token; | ||
| const expiresIn = Number(data.expires_in) || 0; | ||
| const vars = {}; | ||
| if (access) vars.TIKTOK_ACCESS_TOKEN = access; | ||
| if (refresh) vars.TIKTOK_REFRESH_TOKEN = refresh; | ||
| if (access) vars.TIKTOK_TOKEN_EXPIRES_AT = String(Date.now() + expiresIn * 1000); | ||
| if (Object.keys(vars).length) writeEnv(vars); | ||
| return vars; | ||
| } | ||
| // Return a valid access token, refreshing if it expires within the buffer (or is | ||
| // already gone). Throws (never returns a stale token) so callers fail loud. | ||
| async function getAccessToken(force = false) { | ||
| const token = readEnv('TIKTOK_ACCESS_TOKEN'); | ||
| const expiresAt = Number(readEnv('TIKTOK_TOKEN_EXPIRES_AT') || 0); | ||
| if (token && !force && expiresAt - Date.now() > REFRESH_BUFFER_MS) return token; | ||
| const refresh = readEnv('TIKTOK_REFRESH_TOKEN'); | ||
| if (!refresh) { | ||
| // No refresh token yet but an unexpired access token exists -> use it. | ||
| if (token && expiresAt > Date.now()) return token; | ||
| console.error("[err] TIKTOK_REFRESH_TOKEN missing - run 'node scripts/tiktok-social.mjs auth' first."); | ||
| process.exit(1); | ||
| } | ||
| const data = await tokenExchange({ | ||
| grant_type: 'refresh_token', | ||
| refresh_token: refresh, | ||
| client_key: requireEnv('TIKTOK_CLIENT_KEY'), | ||
| client_secret: requireEnv('TIKTOK_CLIENT_SECRET'), | ||
| }); | ||
| const vars = persistTokens(data); | ||
| if (!vars.TIKTOK_ACCESS_TOKEN) throw new Error('refresh returned no access_token.'); | ||
| return vars.TIKTOK_ACCESS_TOKEN; | ||
| } | ||
| // ---------- Content Posting API helper (bearer JSON POST) ---------- | ||
| async function ttPost(url, body, token) { | ||
| const res = await fetch(url, { | ||
| method: 'POST', | ||
| headers: { | ||
| Authorization: `Bearer ${token}`, | ||
| 'Content-Type': 'application/json; charset=UTF-8', | ||
| }, | ||
| body: JSON.stringify(body || {}), | ||
| }); | ||
| const text = await res.text(); | ||
| let data; | ||
| try { data = text ? JSON.parse(text) : {}; } catch { data = { raw: text }; } | ||
| // TikTok wraps responses in { data, error:{ code, message, log_id } }. code 'ok' | ||
| // means success; anything else (or a non-2xx) is a failure. | ||
| const err = data.error || {}; | ||
| if (!res.ok || (err.code && err.code !== 'ok')) { | ||
| throw new Error(`TikTok ${new URL(url).pathname}: HTTP ${res.status} ${err.code || ''} - ${err.message || data.raw || text || 'unknown'}`); | ||
| } | ||
| return data; | ||
| } | ||
| async function ttGet(url, token) { | ||
| const res = await fetch(url, { method: 'GET', headers: { Authorization: `Bearer ${token}` } }); | ||
| const text = await res.text(); | ||
| let data; | ||
| try { data = text ? JSON.parse(text) : {}; } catch { data = { raw: text }; } | ||
| const err = data.error || {}; | ||
| if (!res.ok || (err.code && err.code !== 'ok')) { | ||
| throw new Error(`TikTok ${new URL(url).pathname}: HTTP ${res.status} ${err.code || ''} - ${err.message || data.raw || text || 'unknown'}`); | ||
| } | ||
| return data; | ||
| } | ||
| // ---------- plan helpers (same shape as the sibling engines) ---------- | ||
| function loadPlan(planPath) { | ||
| const abs = path.resolve(planPath); | ||
| return { abs, plan: JSON.parse(fs.readFileSync(abs, 'utf8')) }; | ||
| } | ||
| const ENGINE_OWNED_FIELDS = ['fbPostId', 'fbReelId', 'igMediaId', 'liPostId', 'ytVideoId', 'xPostId', 'tgMessageId', 'dcMessageId', 'tiktokVideoId', 'status', 'postedAt', 'attempts']; | ||
| async function withPlanLock(abs, fn) { | ||
| const lockDir = `${abs}.lock.d`; | ||
| for (let i = 0; ; i++) { | ||
| try { fs.mkdirSync(lockDir); break; } catch (err) { | ||
| if (err.code !== 'EEXIST') throw err; | ||
| let ageMs = 0; | ||
| try { ageMs = Date.now() - fs.statSync(lockDir).mtimeMs; } catch { continue; } | ||
| if (ageMs > 15 * 60 * 1000) { try { fs.rmdirSync(lockDir); } catch { /* racing steal */ } continue; } | ||
| if (i >= 5) throw new Error(`plan lock busy: ${lockDir}`); | ||
| await new Promise((r) => setTimeout(r, 200)); | ||
| } | ||
| } | ||
| try { return fn(); } finally { try { fs.rmdirSync(lockDir); } catch { /* released */ } } | ||
| } | ||
| async function savePlan(abs, plan, touchedIds = null) { | ||
| await withPlanLock(abs, () => { | ||
| let out = plan; | ||
| if (Array.isArray(touchedIds)) { | ||
| try { | ||
| const disk = JSON.parse(fs.readFileSync(abs, 'utf8')); | ||
| for (const id of touchedIds) { | ||
| const mem = (plan.posts || []).find((p) => p.id === id); | ||
| const target = (disk.posts || []).find((p) => p.id === id); | ||
| if (!mem || !target) continue; | ||
| for (const f of ENGINE_OWNED_FIELDS) if (mem[f] !== undefined) target[f] = mem[f]; | ||
| } | ||
| out = disk; | ||
| } catch { /* unreadable disk copy - fall back to in-memory plan */ } | ||
| } | ||
| const tmp = `${abs}.tmp-${process.pid}`; | ||
| fs.writeFileSync(tmp, `${JSON.stringify(out, null, 2)}\n`); | ||
| fs.renameSync(tmp, abs); | ||
| }); | ||
| } | ||
| function appendAttempt(post, entry) { | ||
| post.attempts = Array.isArray(post.attempts) ? post.attempts : []; | ||
| post.attempts.push(entry); | ||
| } | ||
| const RUN = { results: [] }; | ||
| let JSON_MODE = false; | ||
| let ACTOR = 'cli'; | ||
| function resolveMediaPath(plan, post) { | ||
| const root = process.env.PENDPOST_ROOT ? path.resolve(process.env.PENDPOST_ROOT) : path.resolve(__dirname, '..'); | ||
| if (post.path) { | ||
| const abs = path.isAbsolute(post.path) ? post.path : path.resolve(root, post.path); | ||
| if (fs.existsSync(abs)) return abs; | ||
| } | ||
| if (post.file) { | ||
| const rel = path.join(plan.folder || '', post.file); | ||
| const abs = path.isAbsolute(rel) ? rel : path.resolve(root, rel); | ||
| if (fs.existsSync(abs)) return abs; | ||
| } | ||
| return null; | ||
| } | ||
| const isTikTok = (post) => (post.platforms || []).includes('tiktok'); | ||
| const isVideo = (localPath) => /\.(mp4|mov|m4v)$/i.test(localPath || ''); | ||
| const captionText = (post) => (post.ttCaption || post.caption || '').trim(); | ||
| // Privacy level for a post: SELF_ONLY by default (unaudited-safe). A post may | ||
| // request a wider level via post.ttPrivacy; TikTok enforces the audit requirement | ||
| // server-side (a wider level on an un-audited app fails at publish, not here). | ||
| function privacyFor(post) { | ||
| const wanted = (post.ttPrivacy || '').toString().trim().toUpperCase(); | ||
| const allowed = ['SELF_ONLY', 'PUBLIC_TO_EVERYONE', 'MUTUAL_FOLLOW_FRIENDS', 'FOLLOWER_OF_CREATOR']; | ||
| return allowed.includes(wanted) ? wanted : PRIVACY_LEVEL; | ||
| } | ||
| // ---------- the three-step direct-post upload ---------- | ||
| // 1) INIT: reserve a publish_id + upload_url for a single-chunk FILE_UPLOAD. | ||
| async function initUpload(post, mediaPath, token) { | ||
| const size = fs.statSync(mediaPath).size; | ||
| const body = { | ||
| post_info: { | ||
| title: captionText(post).slice(0, CAPTION_LIMIT), | ||
| privacy_level: privacyFor(post), | ||
| }, | ||
| source_info: { | ||
| source: 'FILE_UPLOAD', | ||
| video_size: size, | ||
| chunk_size: size, // single chunk | ||
| total_chunk_count: 1, | ||
| }, | ||
| }; | ||
| const res = await ttPost(INIT_URL, body, token); | ||
| const publishId = res?.data?.publish_id; | ||
| const uploadUrl = res?.data?.upload_url; | ||
| if (!publishId || !uploadUrl) { | ||
| throw new Error(`init returned no publish_id/upload_url: ${JSON.stringify(res?.data || res).slice(0, 200)}`); | ||
| } | ||
| return { publishId, uploadUrl, size }; | ||
| } | ||
| // 2) UPLOAD: PUT the whole file as a single content range. TikTok expects the | ||
| // byte-exact Content-Range (the dance is unforgiving about an off-by-one). | ||
| async function uploadBytes(uploadUrl, mediaPath, size) { | ||
| const buf = fs.readFileSync(mediaPath); | ||
| const res = await fetch(uploadUrl, { | ||
| method: 'PUT', | ||
| headers: { | ||
| 'Content-Type': 'video/mp4', | ||
| 'Content-Range': `bytes 0-${size - 1}/${size}`, | ||
| 'Content-Length': String(size), | ||
| }, | ||
| body: buf, | ||
| }); | ||
| if (!res.ok) { | ||
| const t = await res.text().catch(() => ''); | ||
| throw new Error(`upload PUT: HTTP ${res.status} - ${t.slice(0, 200) || 'unknown'}`); | ||
| } | ||
| } | ||
| // 3) POLL: status/fetch until PUBLISH_COMPLETE | FAILED, bounded with backoff. | ||
| // Returns { status, videoId } where videoId is best-effort (TikTok does not | ||
| // always surface a post id here). | ||
| async function pollStatus(publishId, token) { | ||
| let last = null; | ||
| for (let i = 0; i < POLL_MAX_TRIES; i++) { | ||
| await new Promise((r) => setTimeout(r, POLL_BASE_MS * (i + 1))); | ||
| let res; | ||
| try { | ||
| res = await ttPost(STATUS_URL, { publish_id: publishId }, token); | ||
| } catch (err) { | ||
| last = { error: err.message }; | ||
| continue; // transient status read - keep trying within the bound | ||
| } | ||
| const d = res?.data || {}; | ||
| const status = d.status || d.publish_status || ''; | ||
| last = d; | ||
| if (status === 'PUBLISH_COMPLETE') { | ||
| // publicly_available_post_id is an array on success in some API versions. | ||
| const videoId = Array.isArray(d.publicly_available_post_id) | ||
| ? d.publicly_available_post_id[0] | ||
| : (d.publicly_available_post_id || d.post_id || null); | ||
| return { status, videoId: videoId ? String(videoId) : null }; | ||
| } | ||
| if (status === 'FAILED') { | ||
| const reason = d.fail_reason || d.failure_reason || 'unknown'; | ||
| throw new Error(`TikTok processing FAILED: ${reason}`); | ||
| } | ||
| // PROCESSING_UPLOAD / PROCESSING_DOWNLOAD / SEND_TO_USER_INBOX -> keep polling. | ||
| } | ||
| // Bounded out without a terminal state. The post may still complete async; we | ||
| // return the publish_id so the operator can verify later rather than erroring. | ||
| const seen = last && typeof last === 'object' ? (last.status || last.publish_status || 'unknown') : 'unknown'; | ||
| return { status: `PENDING (last seen: ${seen})`, videoId: null }; | ||
| } | ||
| // ---------- commands ---------- | ||
| async function cmdAuth(args) { | ||
| console.log(`[info] Connecting TikTok - credentials will be written to ${ENV_PATH}`); | ||
| const clientKey = args['client-key'] || args['client-id'] || readEnv('TIKTOK_CLIENT_KEY'); | ||
| const clientSecret = args['client-secret'] || readEnv('TIKTOK_CLIENT_SECRET'); | ||
| if (!clientKey || !clientSecret) { | ||
| console.error('[err] Need --client-key and --client-secret (TikTok for Developers -> your app -> credentials) on first run, or set TIKTOK_CLIENT_KEY / TIKTOK_CLIENT_SECRET in .env.'); | ||
| process.exit(2); | ||
| } | ||
| const redirect = redirectUri(); | ||
| writeEnv({ TIKTOK_CLIENT_KEY: clientKey, TIKTOK_CLIENT_SECRET: clientSecret, TIKTOK_REDIRECT_URI: redirect }); | ||
| const u = new URL(redirect); | ||
| const port = Number(u.port || 80); | ||
| const callbackPath = u.pathname || '/oauth/tiktok/callback'; | ||
| const state = crypto.randomUUID(); | ||
| const authUrl = `${AUTH_URL}?${new URLSearchParams({ | ||
| client_key: clientKey, | ||
| response_type: 'code', | ||
| scope: SCOPES.join(','), | ||
| redirect_uri: redirect, | ||
| state, | ||
| }).toString()}`; | ||
| console.log(`\n[action] Make sure ${redirect} is registered as a Redirect URI for this TikTok app (TikTok for Developers -> Login Kit -> Redirect URI).`); | ||
| console.log('[action] Opening the TikTok consent screen. Sign in with the account that will publish. If it does not open, paste this URL:\n'); | ||
| console.log(` ${authUrl}\n`); | ||
| console.log('[note] Unaudited apps can only post privately (SELF_ONLY) to a few allowlisted users; public posting requires a per-app content audit (2-6 weeks).'); | ||
| await new Promise((resolve, reject) => { | ||
| const server = http.createServer(async (req, res) => { | ||
| const ru = new URL(req.url, redirect); | ||
| if (ru.pathname !== callbackPath) { res.writeHead(404); res.end('not found'); return; } | ||
| const error = ru.searchParams.get('error'); | ||
| if (error) { | ||
| res.writeHead(200, { 'Content-Type': 'text/html' }); | ||
| res.end(`<h2>Authorization denied: ${error}</h2><p>${ru.searchParams.get('error_description') || ''}</p>`); | ||
| server.close(); | ||
| reject(new Error(`Authorization denied: ${error} - ${ru.searchParams.get('error_description') || ''}`)); | ||
| return; | ||
| } | ||
| const code = ru.searchParams.get('code'); | ||
| if (!code) { res.writeHead(400); res.end('missing code'); return; } | ||
| if (ru.searchParams.get('state') !== state) { | ||
| res.writeHead(400); res.end('state mismatch'); | ||
| server.close(); | ||
| reject(new Error('OAuth state mismatch - possible CSRF; aborted.')); | ||
| return; | ||
| } | ||
| try { | ||
| const data = await tokenExchange({ | ||
| grant_type: 'authorization_code', | ||
| code, | ||
| client_key: clientKey, | ||
| client_secret: clientSecret, | ||
| redirect_uri: redirect, | ||
| }); | ||
| const vars = persistTokens(data); | ||
| if (!vars.TIKTOK_ACCESS_TOKEN) throw new Error('token exchange returned no access_token.'); | ||
| if (!vars.TIKTOK_REFRESH_TOKEN) console.error('[warn] No refresh_token returned - re-auth will be needed when the access token expires.'); | ||
| res.writeHead(200, { 'Content-Type': 'text/html' }); | ||
| res.end('<h2>pendpost: TikTok connected.</h2><p>You can close this tab and return to the terminal.</p>'); | ||
| console.log(`\n[ok] Access token stored (${tokenTail(vars.TIKTOK_ACCESS_TOKEN)}).`); | ||
| if (vars.TIKTOK_REFRESH_TOKEN) console.log(`[ok] Refresh token stored (${tokenTail(vars.TIKTOK_REFRESH_TOKEN)}).`); | ||
| if (vars.TIKTOK_TOKEN_EXPIRES_AT) console.log(`[ok] Access token expires ${new Date(Number(vars.TIKTOK_TOKEN_EXPIRES_AT)).toLocaleString('en-US')}.`); | ||
| server.close(); | ||
| resolve(); | ||
| } catch (err) { | ||
| res.writeHead(500, { 'Content-Type': 'text/html' }); | ||
| res.end(`<h2>Token exchange failed</h2><pre>${err.message}</pre>`); | ||
| server.close(); | ||
| reject(err); | ||
| } | ||
| }); | ||
| server.on('error', reject); | ||
| server.listen(port, () => { | ||
| execFile('open', [authUrl], () => {}); // best-effort browser open on macOS (no shell -> no injection) | ||
| console.log(`[info] Waiting for the TikTok consent redirect on ${redirect} ...`); | ||
| }); | ||
| }); | ||
| RUN.results.push({ platform: 'tiktok', action: 'auth', ok: true, detail: 'tokens stored', tokenExpiresAt: Number(readEnv('TIKTOK_TOKEN_EXPIRES_AT')) || null }); | ||
| } | ||
| async function cmdRefresh() { | ||
| const token = await getAccessToken(true); | ||
| const expiresAt = Number(readEnv('TIKTOK_TOKEN_EXPIRES_AT')) || null; | ||
| console.log(`[ok] Access token refreshed ${tokenTail(token)}${expiresAt ? `, expires ${new Date(expiresAt).toLocaleString('en-US')}` : ''}.`); | ||
| RUN.results.push({ platform: 'tiktok', action: 'refresh', ok: true, tokenExpiresAt: expiresAt }); | ||
| } | ||
| async function cmdValidate(args) { | ||
| const { plan } = loadPlan(args.plan); | ||
| console.log('================ VALIDATION ONLY - NOTHING WILL BE PUBLISHED ================'); | ||
| try { | ||
| const token = await getAccessToken(); | ||
| const info = await ttGet(USERINFO_URL, token); | ||
| const name = info?.data?.user?.display_name || info?.data?.user?.union_id || 'connected'; | ||
| console.log(`[ok] Token valid - authenticated as ${name}.`); | ||
| } catch (err) { | ||
| console.log(`[warn] auth/probe failed (${err.message}). Continuing to caption preview.`); | ||
| } | ||
| const targets = (plan.posts || []).filter((p) => isTikTok(p) && (!args.only || p.id === args.only)); | ||
| if (!targets.length) { console.log('[warn] No TikTok entries match.'); return; } | ||
| for (const post of targets) { | ||
| const text = captionText(post); | ||
| console.log(`\n----- ${post.id} -----`); | ||
| console.log(`[preview] type: ${post.type}`); | ||
| console.log(`[preview] privacy: ${privacyFor(post)}${privacyFor(post) === PRIVACY_LEVEL ? ' (default - unaudited-safe)' : ''}`); | ||
| console.log(`[preview] caption (${text.length}/${CAPTION_LIMIT}${text.length > CAPTION_LIMIT ? ' - OVER LIMIT' : ''}):`); | ||
| console.log(text); | ||
| const mediaPath = resolveMediaPath(plan, post); | ||
| if (!mediaPath) console.log(`[warn] media not found (${post.path || post.file}) - TikTok requires a video.`); | ||
| else if (!isVideo(mediaPath)) console.log(`[warn] media ${path.basename(mediaPath)} is not a video - TikTok publishes video only.`); | ||
| else console.log(`[preview] video: ${path.basename(mediaPath)} (${(fs.statSync(mediaPath).size / 1e6).toFixed(1)} MB)`); | ||
| } | ||
| console.log('\n================ VALIDATION COMPLETE ================'); | ||
| } | ||
| async function cmdPublishDue(args) { | ||
| const { abs, plan } = loadPlan(args.plan); | ||
| const now = Date.now(); | ||
| let published = 0; | ||
| for (const post of plan.posts || []) { | ||
| if (args.only && post.id !== args.only) continue; | ||
| if (!isTikTok(post)) continue; | ||
| if (post.executionMode !== 'fully-scheduled') continue; | ||
| if (post.status !== 'planned') continue; | ||
| if ((post.approval || 'draft') !== 'approved') { | ||
| console.log(`[skip] ${post.id}: approval is "${post.approval || 'draft'}" - only approved posts publish.`); | ||
| continue; | ||
| } | ||
| const dueMs = Date.parse(post.scheduledAt); | ||
| if (Number.isNaN(dueMs) || dueMs > now) continue; | ||
| const text = captionText(post); | ||
| if (text.length > CAPTION_LIMIT) { console.log(`[warn] ${post.id}: caption is ${text.length} chars (> ${CAPTION_LIMIT}) - skipping.`); continue; } | ||
| // TikTok requires a video render - text-only posts are not publishable here. | ||
| const mediaPath = resolveMediaPath(plan, post); | ||
| if (!mediaPath) { console.log(`[warn] ${post.id}: due but local media not found (${post.path || post.file}) - skipping.`); continue; } | ||
| if (!isVideo(mediaPath)) { console.log(`[warn] ${post.id}: media ${path.basename(mediaPath)} is not a video - TikTok publishes video only; skipping.`); continue; } | ||
| if (args['dry-run']) { | ||
| console.log(`[dry] ${post.id}: would init+upload ${path.basename(mediaPath)} and post privacy=${privacyFor(post)}.`); | ||
| continue; | ||
| } | ||
| console.log(`[info] ${post.id}: publishing video to TikTok (privacy=${privacyFor(post)})...`); | ||
| try { | ||
| const token = await getAccessToken(); | ||
| const { publishId, uploadUrl, size } = await initUpload(post, mediaPath, token); | ||
| // Store the publish_id immediately - if upload/poll fails we still know the | ||
| // reservation that was made (recovery + dedupe), per the assignment. | ||
| post.tiktokVideoId = String(publishId); | ||
| await savePlan(abs, plan, [post.id]); | ||
| await uploadBytes(uploadUrl, mediaPath, size); | ||
| const { status, videoId } = await pollStatus(publishId, token); | ||
| if (videoId) post.tiktokVideoId = String(videoId); | ||
| post.status = 'posted'; | ||
| post.postedAt = new Date(now).toISOString(); | ||
| appendAttempt(post, { ts: new Date().toISOString(), platform: 'tiktok', action: 'publish', ok: true, errorCode: null, errorMessage: null, actor: ACTOR }); | ||
| await savePlan(abs, plan, [post.id]); | ||
| RUN.results.push({ postId: post.id, platform: 'tiktok', action: 'publish', ok: true, id: String(videoId || publishId), detail: status }); | ||
| console.log(`[ok] ${post.id}: published on TikTok (publish_id ${publishId}${videoId ? `, video ${videoId}` : ''}) - ${status}.`); | ||
| published += 1; | ||
| } catch (err) { | ||
| appendAttempt(post, { ts: new Date().toISOString(), platform: 'tiktok', action: 'publish', ok: false, errorCode: 'engine_failure', errorMessage: err.message.slice(0, 300), actor: ACTOR }); | ||
| await savePlan(abs, plan, [post.id]); | ||
| RUN.results.push({ postId: post.id, platform: 'tiktok', action: 'publish', ok: false, errorCode: 'engine_failure', errorMessage: err.message.slice(0, 300) }); | ||
| console.error(`[err] ${post.id}: TikTok publish failed - ${err.message}`); | ||
| continue; | ||
| } | ||
| } | ||
| console.log(`[done] publish-due complete - ${published} video(s) published.`); | ||
| } | ||
| async function cmdStatus(args) { | ||
| const { plan } = loadPlan(args.plan); | ||
| console.log('[info] TikTok plan entries:'); | ||
| for (const post of (plan.posts || []).filter(isTikTok)) { | ||
| console.log(` ${post.id.padEnd(18)} ${String(post.status).padEnd(10)} ${post.scheduledAt} mode=${post.executionMode}${post.tiktokVideoId ? ` tt=${post.tiktokVideoId}` : ''}`); | ||
| } | ||
| } | ||
| // Best-effort liveness: re-fetch the publish status for any stored publish_id. | ||
| // TikTok exposes no public "get post" by id to the posting app, so a stored id + | ||
| // a reachable account is our liveness signal. | ||
| async function cmdVerify(args) { | ||
| const { plan } = loadPlan(args.plan); | ||
| let token = null; | ||
| try { token = await getAccessToken(); } catch { /* surfaced per row */ } | ||
| for (const post of (plan.posts || []).filter(isTikTok)) { | ||
| if (args.only && post.id !== args.only) continue; | ||
| if (!post.tiktokVideoId) continue; | ||
| let live = false; | ||
| let state = 'unknown'; | ||
| if (token) { | ||
| try { | ||
| const res = await ttPost(STATUS_URL, { publish_id: post.tiktokVideoId }, token); | ||
| state = res?.data?.status || res?.data?.publish_status || 'unknown'; | ||
| live = state === 'PUBLISH_COMPLETE'; | ||
| } catch { /* the id may already be a final video id, not a publish_id */ } | ||
| } | ||
| RUN.results.push({ postId: post.id, platform: 'tiktok', action: 'verify', ok: true, live, state, permalink: null, id: post.tiktokVideoId }); | ||
| } | ||
| } | ||
| // TikTok exposes no per-post metrics to a content-posting app - honest no-op. | ||
| async function cmdInsights(args) { | ||
| const { plan } = loadPlan(args.plan); | ||
| void plan; | ||
| console.log('[info] TikTok Content Posting API exposes no per-post metrics - insights is a no-op.'); | ||
| } | ||
| // The Content Posting API cannot delete a published post programmatically. | ||
| async function cmdDelete(args) { | ||
| void args; | ||
| console.log('[info] TikTok provides no delete API for published posts - delete is a no-op (remove the post in the TikTok app).'); | ||
| RUN.results.push({ platform: 'tiktok', action: 'delete', ok: false, errorCode: 'unsupported', errorMessage: 'TikTok has no delete-post API' }); | ||
| } | ||
| async function cmdProbe() { | ||
| if (!readEnv('TIKTOK_ACCESS_TOKEN') && !readEnv('TIKTOK_REFRESH_TOKEN')) { | ||
| RUN.results.push({ platform: 'tiktok', action: 'probe', ok: false, detail: 'not configured (run auth)' }); | ||
| return; | ||
| } | ||
| try { | ||
| const token = await getAccessToken(); | ||
| const info = await ttGet(USERINFO_URL, token); | ||
| const name = info?.data?.user?.display_name || info?.data?.user?.union_id || 'connected'; | ||
| const expiresAt = Number(readEnv('TIKTOK_TOKEN_EXPIRES_AT') || 0) || null; | ||
| RUN.results.push({ platform: 'tiktok', action: 'probe', ok: true, detail: `connected as ${name}`, tokenExpiresAt: expiresAt }); | ||
| } catch (err) { | ||
| RUN.results.push({ platform: 'tiktok', action: 'probe', ok: false, detail: String(err.message || err).slice(0, 200) }); | ||
| } | ||
| } | ||
| // ---------- main ---------- | ||
| function parseArgs(argv) { | ||
| const args = { _: [] }; | ||
| for (let i = 2; i < argv.length; i++) { | ||
| const a = argv[i]; | ||
| if (a.startsWith('--')) { | ||
| const key = a.slice(2); | ||
| const next = argv[i + 1]; | ||
| if (next === undefined || next.startsWith('--')) args[key] = true; | ||
| else args[key] = argv[++i]; | ||
| } else args._.push(a); | ||
| } | ||
| return args; | ||
| } | ||
| const COMMANDS = { | ||
| auth: cmdAuth, | ||
| connect: cmdAuth, | ||
| refresh: cmdRefresh, | ||
| validate: cmdValidate, | ||
| 'publish-due': cmdPublishDue, | ||
| status: cmdStatus, | ||
| verify: cmdVerify, | ||
| insights: cmdInsights, | ||
| delete: cmdDelete, | ||
| probe: cmdProbe, | ||
| }; | ||
| async function main() { | ||
| const args = parseArgs(process.argv); | ||
| JSON_MODE = Boolean(args.json); | ||
| ACTOR = typeof args.actor === 'string' ? args.actor : 'cli'; | ||
| if (JSON_MODE) console.log = (...a) => console.error(...a); | ||
| const commandName = args._[0]; | ||
| if (resolveMode('tiktok') === 'mock' && isMockableCommand(commandName)) { | ||
| const envelope = await runMockCommand({ | ||
| platform: 'tiktok', command: commandName, | ||
| planPath: typeof args.plan === 'string' ? path.resolve(String(args.plan)) : null, | ||
| only: typeof args.only === 'string' ? args.only : null, | ||
| }); | ||
| if (JSON_MODE) process.stdout.write(`${JSON.stringify(envelope)}\n`); | ||
| else console.error(`[mock] tiktok ${commandName}: ${envelope.results.length} result(s)`); | ||
| return; | ||
| } | ||
| const cmd = COMMANDS[commandName]; | ||
| if (!cmd) { | ||
| console.error(`Usage: node scripts/tiktok-social.mjs <${Object.keys(COMMANDS).join('|')}> [options]`); | ||
| process.exit(2); | ||
| } | ||
| if (['validate', 'publish-due', 'status', 'verify', 'insights'].includes(commandName) && !args.plan) { | ||
| console.error(`[err] ${commandName} requires --plan <post-plan.json>`); | ||
| process.exit(2); | ||
| } | ||
| await cmd(args); | ||
| if (JSON_MODE) process.stdout.write(`${JSON.stringify({ ok: true, ...RUN })}\n`); | ||
| } | ||
| main().catch(async (err) => { | ||
| console.error('[err]', err.message || err); | ||
| if (JSON_MODE) process.stdout.write(`${JSON.stringify({ ok: false, error: String(err.message || err).slice(0, 300), ...RUN })}\n`); | ||
| process.exit(1); | ||
| }); |
@@ -32,4 +32,4 @@ <!doctype html> | ||
| </style> | ||
| <script type="module" crossorigin src="/assets/index-CLbOnpJ6.js"></script> | ||
| <link rel="stylesheet" crossorigin href="/assets/index-C3SH6MsN.css"> | ||
| <script type="module" crossorigin src="/assets/index-ClRLc0C4.js"></script> | ||
| <link rel="stylesheet" crossorigin href="/assets/index-DCOqUPrq.css"> | ||
| </head> | ||
@@ -36,0 +36,0 @@ <body> |
+71
-0
@@ -158,2 +158,6 @@ // accounts.mjs - per-platform connection health from env presence + service state. | ||
| const xHandle = (readEnv('X_HANDLE') || '').replace(/^@/, '').trim(); | ||
| // Telegram: a public @username channel has a t.me link; a numeric chat id has none. | ||
| const tgChannel = (readEnv('TELEGRAM_CHANNEL_ID') || '').trim(); | ||
| // Reddit: the target subreddit has a public URL; the posting account is incidental. | ||
| const redditSub = (readEnv('REDDIT_SUBREDDIT') || '').replace(/^\/?r\//, '').trim(); | ||
| return { | ||
@@ -165,2 +169,10 @@ instagram: igHandle ? `https://www.instagram.com/${igHandle}` : null, | ||
| x: xHandle ? `https://x.com/${xHandle}` : null, | ||
| telegram: tgChannel.startsWith('@') ? `https://t.me/${tgChannel.slice(1)}` : null, | ||
| // A Discord channel webhook has no public, shareable account URL. | ||
| discord: null, | ||
| reddit: redditSub ? `https://www.reddit.com/r/${redditSub}` : null, | ||
| // Pinterest: no account-level URL is derivable from a board id alone. | ||
| pinterest: null, | ||
| // TikTok: the profile URL needs the creator @username (not stored as an identifier). | ||
| tiktok: null, | ||
| }; | ||
@@ -174,2 +186,4 @@ } | ||
| const xExpiresAt = Number(readEnv('X_TOKEN_EXPIRES_AT') || 0) || null; | ||
| const pinExpiresAt = Number(readEnv('PINTEREST_TOKEN_EXPIRES_AT') || 0) || null; | ||
| const ttExpiresAt = Number(readEnv('TIKTOK_TOKEN_EXPIRES_AT') || 0) || null; | ||
| // live.<platform> = the last liveness probe (lib/health.mjs); presence-only | ||
@@ -245,2 +259,59 @@ // flags above say a credential EXISTS, live says it actually AUTHENTICATES. | ||
| }, | ||
| telegram: { | ||
| mode: resolveMode('telegram'), | ||
| // Static bot token: configured == authenticated. Channel id is also needed to | ||
| // publish, so a missing channel surfaces in the hint without blocking the probe. | ||
| configured: Boolean(readEnv('TELEGRAM_BOT_TOKEN')), | ||
| authenticated: Boolean(readEnv('TELEGRAM_BOT_TOKEN')), | ||
| channelId: readEnv('TELEGRAM_CHANNEL_ID') || '', | ||
| tokenExpiry: 'static bot token (no expiry)', | ||
| hint: readEnv('TELEGRAM_BOT_TOKEN') | ||
| ? (readEnv('TELEGRAM_CHANNEL_ID') ? null : 'Bot token set but TELEGRAM_CHANNEL_ID is missing - set the destination channel.') | ||
| : 'Not connected - get a bot token from @BotFather, then node scripts/telegram-social.mjs auth', | ||
| live: live.telegram || null, | ||
| }, | ||
| discord: { | ||
| mode: resolveMode('discord'), | ||
| configured: Boolean(readEnv('DISCORD_WEBHOOK_URL')), | ||
| authenticated: Boolean(readEnv('DISCORD_WEBHOOK_URL')), | ||
| tokenExpiry: 'static webhook URL (no expiry)', | ||
| hint: readEnv('DISCORD_WEBHOOK_URL') ? null : 'Not connected - create a channel webhook, then node scripts/discord-social.mjs auth', | ||
| live: live.discord || null, | ||
| }, | ||
| reddit: { | ||
| mode: resolveMode('reddit'), | ||
| // Static script-app credentials: a client id + posting username are the | ||
| // minimum to mint a token, so configured == authenticated here. | ||
| configured: Boolean(readEnv('REDDIT_CLIENT_ID') && readEnv('REDDIT_USERNAME')), | ||
| authenticated: Boolean(readEnv('REDDIT_CLIENT_ID') && readEnv('REDDIT_USERNAME')), | ||
| subreddit: (readEnv('REDDIT_SUBREDDIT') || '').replace(/^\/?r\//, '').trim(), | ||
| tokenExpiry: 'short-lived bearer, minted per run', | ||
| hint: (readEnv('REDDIT_CLIENT_ID') && readEnv('REDDIT_USERNAME')) | ||
| ? (readEnv('REDDIT_SUBREDDIT') ? null : 'Credentials set but REDDIT_SUBREDDIT is missing - set the target subreddit.') | ||
| : 'Not connected - create a script app at reddit.com/prefs/apps, then node scripts/reddit-social.mjs auth', | ||
| live: live.reddit || null, | ||
| }, | ||
| pinterest: { | ||
| mode: resolveMode('pinterest'), | ||
| configured: Boolean(readEnv('PINTEREST_APP_ID') && readEnv('PINTEREST_APP_SECRET')), | ||
| authenticated: Boolean(readEnv('PINTEREST_ACCESS_TOKEN') || readEnv('PINTEREST_REFRESH_TOKEN')), | ||
| boardId: readEnv('PINTEREST_BOARD_ID') || '', | ||
| tokenExpiresAt: pinExpiresAt ? new Date(pinExpiresAt).toISOString() : null, | ||
| tokenExpiry: 'short-lived access token, auto-refreshed', | ||
| hint: (readEnv('PINTEREST_ACCESS_TOKEN') || readEnv('PINTEREST_REFRESH_TOKEN')) | ||
| ? (readEnv('PINTEREST_BOARD_ID') ? null : 'Connected but PINTEREST_BOARD_ID is missing - set the target board.') | ||
| : 'Not connected - create an app at developers.pinterest.com, then node scripts/pinterest-social.mjs auth', | ||
| live: live.pinterest || null, | ||
| }, | ||
| tiktok: { | ||
| mode: resolveMode('tiktok'), | ||
| configured: Boolean(readEnv('TIKTOK_CLIENT_KEY') && readEnv('TIKTOK_CLIENT_SECRET')), | ||
| authenticated: Boolean(readEnv('TIKTOK_ACCESS_TOKEN') || readEnv('TIKTOK_REFRESH_TOKEN')), | ||
| tokenExpiresAt: ttExpiresAt ? new Date(ttExpiresAt).toISOString() : null, | ||
| tokenExpiry: 'short-lived access token, auto-refreshed', | ||
| hint: (readEnv('TIKTOK_ACCESS_TOKEN') || readEnv('TIKTOK_REFRESH_TOKEN')) | ||
| ? null | ||
| : 'Not connected - create an app at developers.tiktok.com, then node scripts/tiktok-social.mjs auth', | ||
| live: live.tiktok || null, | ||
| }, | ||
| scheduler: { | ||
@@ -247,0 +318,0 @@ running: schedulerRunning(), |
+89
-32
@@ -6,3 +6,3 @@ // api.mjs - JSON API for the dashboard. | ||
| // MCP tool (mcpTool) - the UI face and the MCP face must ship together. | ||
| import { sendJson, readBody, readBodyRaw, errorBody, VERSION, readEnv } from './util.mjs'; | ||
| import { sendJson, readBody, readBodyRaw, errorBody, VERSION, readEnv, writeEnvVars } from './util.mjs'; | ||
| import { buildId, isBuilding, readUpdateStatus, updateDecision, REPO_ROOT } from './dashboard.mjs'; | ||
@@ -33,3 +33,3 @@ import { execFile, spawn } from 'node:child_process'; | ||
| import { clientRoot, activeClientId } from './multi-client.mjs'; | ||
| import { getCloudStatus, setCloudEnabled, connectWorkspace, pushApprovedJobs, reconcileAlwaysOnBrands, disconnectWorkspace, signOutWorkspace, consolidateCloudKey, handLocalTokens, migrateToCloud, beginEnableConnect, completeEnableConnect, cloudClients, setClientAlwaysOn, getSubscription, startCheckout, startBillingPortal, setSpendCap } from './cloud-client.mjs'; | ||
| import { getCloudStatus, setCloudEnabled, connectWorkspace, pushApprovedJobs, reconcileAlwaysOnBrands, disconnectWorkspace, signOutWorkspace, consolidateCloudKey, handLocalTokens, migrateToCloud, beginEnableConnect, completeEnableConnect, cloudClients, setClientAlwaysOn, getSubscription, startCheckout, startBillingPortal, setSpendCap, cloudSyncStatus } from './cloud-client.mjs'; | ||
@@ -124,6 +124,9 @@ // Parses the body and guarantees a plain object - `null`, arrays and scalars | ||
| // Connect ceremony: platform -> the engine connect command. youtube/linkedin/x mint | ||
| // via an interactive browser OAuth (auth); meta exchanges a long-lived System User | ||
| // token (setup-system-user, no browser). idEnv is the persisted public client-id key | ||
| // that lets a re-auth (expired token) run with no re-entry. | ||
| // Connect ceremony: platform -> the engine connect command. youtube/linkedin/x and | ||
| // pinterest/tiktok mint via an interactive browser OAuth (auth); meta exchanges a | ||
| // long-lived System User token (setup-system-user, no browser). idEnv is the | ||
| // persisted public client-id key that lets a re-auth (expired token) run with no | ||
| // re-entry. The interactive auth lanes that take their app id/secret on FLAGS carry | ||
| // `appIdFlag`/`appSecretFlag` (pinterest uses --app-id/--app-secret, tiktok uses | ||
| // --client-key/--client-secret); youtube/linkedin/x default to --client-id/--client-secret. | ||
| const CONNECT_LANES = { | ||
@@ -134,3 +137,17 @@ youtube: { script: 'yt-social.mjs', cmd: 'auth', interactive: true, idEnv: 'YT_CLIENT_ID' }, | ||
| meta: { script: 'meta-social.mjs', cmd: 'setup-system-user', interactive: false }, | ||
| pinterest: { script: 'pinterest-social.mjs', cmd: 'auth', interactive: true, idEnv: 'PINTEREST_APP_ID', appIdFlag: '--app-id', appSecretFlag: '--app-secret' }, | ||
| tiktok: { script: 'tiktok-social.mjs', cmd: 'auth', interactive: true, idEnv: 'TIKTOK_CLIENT_KEY', appIdFlag: '--client-key', appSecretFlag: '--client-secret' }, | ||
| }; | ||
| // STATIC-credential lanes: NO browser OAuth. The operator types the static secrets, | ||
| // the handler persists them to the active client's .env (the exact contract env keys | ||
| // only, writeEnvVars is whitelist-bounded by THIS caller), then spawns `<script> auth` | ||
| // (no flags - it reads the freshly written .env) and the connect-run registry tracks | ||
| // the exit code. Each entry maps a request body field -> the .env key it lands in. | ||
| // Secret VALUES never reach a log or a response. | ||
| const STATIC_LANES = { | ||
| telegram: { script: 'telegram-social.mjs', fields: { botToken: 'TELEGRAM_BOT_TOKEN', channelId: 'TELEGRAM_CHANNEL_ID' } }, | ||
| discord: { script: 'discord-social.mjs', fields: { webhookUrl: 'DISCORD_WEBHOOK_URL' } }, | ||
| reddit: { script: 'reddit-social.mjs', fields: { redditClientId: 'REDDIT_CLIENT_ID', redditClientSecret: 'REDDIT_CLIENT_SECRET', redditUsername: 'REDDIT_USERNAME', redditPassword: 'REDDIT_PASSWORD' } }, | ||
| }; | ||
| const cleanArg = (v) => (typeof v === 'string' ? v.trim() : ''); | ||
@@ -236,22 +253,7 @@ // A credential value must never carry a newline/NUL (it would corrupt the .env line) | ||
| // { interactive }. Operator-only (no mcpTool) - minting a credential is never an agent action. | ||
| function startConnect(body) { | ||
| const platform = cleanArg(body && body.platform).toLowerCase(); | ||
| const lane = CONNECT_LANES[platform]; | ||
| if (!lane) return { error: errorBody('invalid_input', `unknown platform "${platform}" (expected youtube | meta | linkedin | x)`) }; | ||
| const args = [lane.cmd]; | ||
| const push = (flag, raw) => { const v = cleanArg(raw); if (!v) return null; const e = badArg(v); if (e) return `${flag.replace('--', '')} ${e}`; args.push(flag, v); return null; }; | ||
| if (platform === 'meta') { | ||
| const token = cleanArg(body.systemUserToken); | ||
| if (!token) return { error: errorBody('invalid_input', 'meta needs a System User token (systemUserToken)') }; | ||
| for (const [field, flag] of [['systemUserToken', '--system-user-token'], ['appId', '--app-id'], ['appSecret', '--app-secret'], ['pageId', '--page-id']]) { | ||
| const e = push(flag, body[field]); if (e) return { error: errorBody('invalid_input', e) }; | ||
| } | ||
| } else { | ||
| const e1 = push('--client-id', body.oauthClientId); if (e1) return { error: errorBody('invalid_input', e1) }; | ||
| const e2 = push('--client-secret', body.clientSecret); if (e2) return { error: errorBody('invalid_input', e2) }; | ||
| // First connect needs creds in the body; a re-auth can rely on the persisted client id. | ||
| if (!cleanArg(body.oauthClientId) && !cleanArg(body.clientSecret) && !readEnv(lane.idEnv)) { | ||
| return { error: errorBody('invalid_input', `${platform} needs a Client ID and Client Secret to connect`) }; | ||
| } | ||
| } | ||
| // Spawn the engine connect command for `platform` with `args` against the active | ||
| // client (PENDPOST_ROOT=activeRoot()), wiring stdout/stderr into the connect-run | ||
| // registry. Shared by the interactive and static paths. `interactive` flags whether | ||
| // the lane opens a browser (the GUI shows the consent URL) vs runs to completion. | ||
| function spawnConnect(platform, script, args, interactive) { | ||
| // A stale child from a prior attempt still holds the loopback OAuth port; SIGTERM | ||
@@ -269,3 +271,3 @@ // it so a retry can rebind (otherwise the engine fails with EADDRINUSE). | ||
| // auto-opens the browser, so the user needs no terminal. | ||
| const child = spawn(process.execPath, [path.join(REPO_ROOT, 'scripts', lane.script), ...args], { | ||
| const child = spawn(process.execPath, [path.join(REPO_ROOT, 'scripts', script), ...args], { | ||
| cwd: REPO_ROOT, env: { ...process.env, PENDPOST_ROOT: activeRoot() }, stdio: ['ignore', 'pipe', 'pipe'], | ||
@@ -281,5 +283,53 @@ }); | ||
| child.unref(); | ||
| return { interactive: lane.interactive }; | ||
| return { interactive }; | ||
| } | ||
| function startConnect(body) { | ||
| const platform = cleanArg(body && body.platform).toLowerCase(); | ||
| const lane = CONNECT_LANES[platform]; | ||
| const staticLane = STATIC_LANES[platform]; | ||
| if (!lane && !staticLane) { | ||
| return { error: errorBody('invalid_input', `unknown platform "${platform}" (expected youtube | meta | linkedin | x | pinterest | tiktok | telegram | discord | reddit)`) }; | ||
| } | ||
| // --- STATIC-credential lanes (telegram | discord | reddit): no browser OAuth. --- | ||
| // Validate every provided field, PERSIST the contract's env keys to the active | ||
| // client's .env (writeEnvVars is whitelist-bounded by the keys we pass here), then | ||
| // spawn `auth` (no flags - it reads the freshly written .env). Secrets never logged. | ||
| if (staticLane) { | ||
| const env = {}; | ||
| for (const [field, envKey] of Object.entries(staticLane.fields)) { | ||
| const v = cleanArg(body[field]); | ||
| if (!v) return { error: errorBody('invalid_input', `${platform} needs ${field}`) }; | ||
| const e = badArg(v); if (e) return { error: errorBody('invalid_input', `${field} ${e}`) }; | ||
| env[envKey] = v; | ||
| } | ||
| try { writeEnvVars(env); } catch { return { error: errorBody('invalid_input', 'could not persist credentials') }; } | ||
| return spawnConnect(platform, staticLane.script, ['auth'], false); | ||
| } | ||
| // --- INTERACTIVE OAuth lanes (youtube | linkedin | x | meta | pinterest | tiktok) --- | ||
| const args = [lane.cmd]; | ||
| const push = (flag, raw) => { const v = cleanArg(raw); if (!v) return null; const e = badArg(v); if (e) return `${flag.replace('--', '')} ${e}`; args.push(flag, v); return null; }; | ||
| if (platform === 'meta') { | ||
| const token = cleanArg(body.systemUserToken); | ||
| if (!token) return { error: errorBody('invalid_input', 'meta needs a System User token (systemUserToken)') }; | ||
| for (const [field, flag] of [['systemUserToken', '--system-user-token'], ['appId', '--app-id'], ['appSecret', '--app-secret'], ['pageId', '--page-id']]) { | ||
| const e = push(flag, body[field]); if (e) return { error: errorBody('invalid_input', e) }; | ||
| } | ||
| } else { | ||
| // pinterest/tiktok read their app id/secret under different flag names than the | ||
| // youtube/linkedin/x default (--client-id/--client-secret); the lane declares them. | ||
| const idFlag = lane.appIdFlag || '--client-id'; | ||
| const secretFlag = lane.appSecretFlag || '--client-secret'; | ||
| const e1 = push(idFlag, body.oauthClientId); if (e1) return { error: errorBody('invalid_input', e1) }; | ||
| const e2 = push(secretFlag, body.clientSecret); if (e2) return { error: errorBody('invalid_input', e2) }; | ||
| // First connect needs creds in the body; a re-auth can rely on the persisted client id. | ||
| if (!cleanArg(body.oauthClientId) && !cleanArg(body.clientSecret) && !readEnv(lane.idEnv)) { | ||
| return { error: errorBody('invalid_input', `${platform} needs a Client ID and Client Secret to connect`) }; | ||
| } | ||
| } | ||
| return spawnConnect(platform, lane.script, args, lane.interactive); | ||
| } | ||
| const ROUTES = [ | ||
@@ -492,4 +542,4 @@ // Cover routes precede the bare /api/plans/<id> prefix route: Express-style | ||
| const platform = cleanArg(url.searchParams.get('platform')).toLowerCase(); | ||
| if (!CONNECT_LANES[platform]) { | ||
| return sendJson(res, 400, errorBody('invalid_input', `unknown platform "${platform}" (expected youtube | meta | linkedin | x)`)); | ||
| if (!CONNECT_LANES[platform] && !STATIC_LANES[platform]) { | ||
| return sendJson(res, 400, errorBody('invalid_input', `unknown platform "${platform}" (expected youtube | meta | linkedin | x | pinterest | tiktok | telegram | discord | reddit)`)); | ||
| } | ||
@@ -771,5 +821,12 @@ return sendJson(res, 200, { ok: true, ...readConnectStatus(platform) }); | ||
| // GET /api/cloud - the managed-cloud status (enabled + baseUrl + workspaceId + | ||
| // api-key PRESENCE only). Never returns the api key or any token value. | ||
| // api-key PRESENCE only) plus `sync`, the guarantee roll-up driving the header | ||
| // dot (green: every approved cloud-lane post is confirmed on the cloud; yellow: | ||
| // push pending; red: broken). Pure local read - never returns a key or token, | ||
| // never blocks on the network; sync is null when the brand is not cloud-managed. | ||
| method: 'GET', path: '/api/cloud', mcpTool: null, | ||
| handler: async (req, res) => sendJson(res, 200, { ok: true, ...getCloudStatus() }), | ||
| handler: async (req, res) => { | ||
| let sync = null; | ||
| try { sync = cloudSyncStatus(); } catch { /* the dot degrades to null, never a 500 */ } | ||
| return sendJson(res, 200, { ok: true, ...getCloudStatus(), sync }); | ||
| }, | ||
| }, | ||
@@ -776,0 +833,0 @@ { |
+137
-2
@@ -28,3 +28,3 @@ // cloud-client.mjs - the OPTIONAL, gated client for the proprietary pendpost-cloud | ||
| import { listClients } from './clients.mjs'; | ||
| import { eligibleDuePosts, lanesOwed, lanePlatforms, ENGINES, appendActivity } from './scheduler.mjs'; | ||
| import { eligibleDuePosts, lanesOwed, lanePlatforms, ENGINES, appendActivity, CLOUD_LANES } from './scheduler.mjs'; | ||
| import { buildPublishJob } from './publish-job.mjs'; | ||
@@ -59,2 +59,28 @@ import { fileSha256, loadState, saveState } from './state.mjs'; | ||
| // Cloud-contact bookkeeping: cloudFetch is the single transport choke point, so it is | ||
| // the one truthful place to stamp "when did we last actually reach the cloud". The | ||
| // stamp (state.cloudContact.okAt) feeds cloudSyncStatus - a stale okAt while a brand | ||
| // is cloud-managed means the guarantee is BROKEN (red dot), which is exactly the | ||
| // silent-outage signal the Jun/Jul incident lacked. Throttled to once per window so a | ||
| // multi-call tick does not churn state.json; failures record the sanitized message | ||
| // (never the key) for the status reason. Best-effort: bookkeeping never breaks a call. | ||
| const CONTACT_STAMP_THROTTLE_MS = 60_000; | ||
| let contactOkStampedMs = 0; | ||
| let contactErrStampedMs = 0; | ||
| function stampCloudContact(ok, message = null) { | ||
| const nowMs = Date.now(); | ||
| if (ok && nowMs - contactOkStampedMs < CONTACT_STAMP_THROTTLE_MS) return; | ||
| if (!ok && nowMs - contactErrStampedMs < CONTACT_STAMP_THROTTLE_MS) return; | ||
| try { | ||
| const s = loadState(); | ||
| const prev = (s.cloudContact && typeof s.cloudContact === 'object') ? s.cloudContact : {}; | ||
| const at = new Date(nowMs).toISOString(); | ||
| s.cloudContact = ok | ||
| ? { ...prev, okAt: at, lastError: null, errorAt: null } | ||
| : { ...prev, lastError: String(message || 'cloud request failed').slice(0, 200), errorAt: at }; | ||
| saveState(); | ||
| if (ok) contactOkStampedMs = nowMs; else contactErrStampedMs = nowMs; | ||
| } catch { /* contact bookkeeping is best-effort */ } | ||
| } | ||
| // One authenticated JSON request to the cloud api. The api key rides ONLY in the | ||
@@ -79,2 +105,3 @@ // Authorization header - it is never placed in a url, a body, or a thrown message, | ||
| } catch (e) { | ||
| stampCloudContact(false, e.message); | ||
| throw new CloudError('network_error', `cloud request failed: ${e.message}`); | ||
@@ -87,4 +114,8 @@ } | ||
| const msg = (data && data.error) || `cloud ${method} ${urlPath} -> HTTP ${res.status}`; | ||
| // An HTTP error still proves the cloud is REACHABLE (it answered) - stamp ok so | ||
| // the red dot means "cannot reach / cannot fire", not "a 4xx happened once". | ||
| stampCloudContact(true); | ||
| throw new CloudError('http_error', msg, res.status); | ||
| } | ||
| stampCloudContact(true); | ||
| return data; | ||
@@ -317,2 +348,26 @@ } | ||
| }); | ||
| const accepted = (result && result.accepted) || []; | ||
| // Persist the ACK per job (state.cloudAccepted, keyed campaign:postId:lane like | ||
| // cloudFailures) so "this job reached the cloud" survives the tick. FIRST-ack-wins: | ||
| // the tick re-pushes idempotently every 60s, so overwriting would reset the clock - | ||
| // the scheduler's backstop grace anchors on when the cloud FIRST had the job. An ack | ||
| // proves acceptance only, NEVER that the job will fire (the workspace-collision | ||
| // incident acked every push and fired nothing) - the overdue leg stays the enforcer. | ||
| if (accepted.length) { | ||
| try { | ||
| const acceptedIds = new Set(accepted.map((a) => a && a.jobId).filter(Boolean)); | ||
| const s = loadState(); | ||
| if (!s.cloudAccepted || typeof s.cloudAccepted !== 'object') s.cloudAccepted = {}; | ||
| const at = new Date().toISOString(); | ||
| let changed = false; | ||
| for (const job of jobs) { | ||
| if (!acceptedIds.has(job.jobId)) continue; | ||
| const key = `${job.identity.campaign}:${job.identity.postId}:${job.lane}`; | ||
| if (s.cloudAccepted[key]) continue; // first-ack-wins | ||
| s.cloudAccepted[key] = { jobId: job.jobId, at }; | ||
| changed = true; | ||
| } | ||
| if (changed) saveState(); | ||
| } catch { /* ack bookkeeping is best-effort; the push result is unchanged */ } | ||
| } | ||
| return { | ||
@@ -322,3 +377,3 @@ ok: true, | ||
| skipped, | ||
| accepted: (result && result.accepted) || [], | ||
| accepted, | ||
| refused: (result && result.refused) || [], | ||
@@ -775,2 +830,11 @@ }; | ||
| for (const p of patched) delete s.cloudFailures[`${p.campaign}:${p.postId}`]; | ||
| // The matching push-ack records are now redundant (the post is posted, so the | ||
| // eligibility walk excludes it anyway); drop every lane's key so cloudAccepted | ||
| // never grows past the live backlog. Keyed campaign:postId:lane - prefix-match. | ||
| if (s.cloudAccepted && typeof s.cloudAccepted === 'object') { | ||
| for (const p of patched) { | ||
| const prefix = `${p.campaign}:${p.postId}:`; | ||
| for (const k of Object.keys(s.cloudAccepted)) if (k.startsWith(prefix)) delete s.cloudAccepted[k]; | ||
| } | ||
| } | ||
| saveState(); | ||
@@ -782,2 +846,73 @@ } catch { /* cache is best-effort */ } | ||
| // ---- the cloud guarantee roll-up (the header dot) --------------------------------- | ||
| // | ||
| // PURE READ, zero network: computed from state.json (the tick's bookkeeping) + the | ||
| // plan store, so /api/cloud stays fast and truthful even while the cloud is down. | ||
| // The contract the owner sees on the header cloud icon: | ||
| // green - every approved, fully-scheduled post owing a CLOUD lane is ack'd by the | ||
| // cloud, contact is fresh, nothing failed: "everything on the cloud WILL post" | ||
| // yellow - >=1 owed cloud-lane job has no push-ack yet (normal for <=1 tick after | ||
| // approving; persistent yellow = pushes not landing) | ||
| // red - the guarantee is BROKEN: cloud unreachable past grace, an approved post | ||
| // overdue-unpublished, a failed cloud fire, sync stopped, or the cloud | ||
| // disagrees the brand is on. The scheduler's backstop then fires locally, | ||
| // but red still demands the owner's attention. | ||
| // An ack proves ACCEPTANCE only, never that the job will fire (the workspace-collision | ||
| // incident acked everything and fired nothing) - which is why overdue-unpublished is a | ||
| // red condition of its own, independent of acks. Scope: CLOUD_LANES only; local-only | ||
| // lanes surface through pendpost_health exactly as before. Returns null when the | ||
| // active brand is not cloud-managed (the dot then falls back to the local states). | ||
| const SYNC_STALE_GRACE_MS = 10 * 60_000; // aligns with pendpost_health's OVERDUE_GRACE_MS | ||
| export function cloudSyncStatus() { | ||
| const cfg = readCloudConfig(); | ||
| const clientId = boundRoot() ? path.basename(boundRoot()) : activeClientId(); | ||
| if (!cfg.workspaceId || !brandAlwaysOn(clientId) || !cloudApiKey()) return null; | ||
| const s = loadState(); | ||
| const now = Date.now(); | ||
| const okAtIso = s.cloudContact && s.cloudContact.okAt ? s.cloudContact.okAt : null; | ||
| const okAt = okAtIso ? Date.parse(okAtIso) : NaN; | ||
| const contactStale = !Number.isFinite(okAt) || (now - okAt) > SYNC_STALE_GRACE_MS; | ||
| const sub = s.cloudSubView && typeof s.cloudSubView === 'object' ? s.cloudSubView : null; | ||
| let pendingCount = 0; | ||
| let overdueCount = 0; | ||
| let manifestBroken = false; | ||
| const openKeys = new Set(); // `${campaign}:${postId}` still approved + unposted | ||
| try { | ||
| const { campaigns, manifestError } = loadPlanStore(); | ||
| if (manifestError) manifestBroken = true; | ||
| const acks = s.cloudAccepted || {}; | ||
| for (const { campaign: c, post } of eligibleDuePosts(campaigns, {})) { | ||
| openKeys.add(`${c.id}:${post.id}`); | ||
| const lanes = lanesOwed(post).filter((l) => CLOUD_LANES.includes(l)); | ||
| if (!lanes.length) continue; | ||
| const due = Date.parse(post.scheduledAt || ''); | ||
| const overdue = Number.isFinite(due) && (now - due) > SYNC_STALE_GRACE_MS; | ||
| for (const lane of lanes) { | ||
| if (overdue) { overdueCount += 1; continue; } | ||
| if (!acks[`${c.id}:${post.id}:${lane}`]) pendingCount += 1; | ||
| } | ||
| } | ||
| } catch { manifestBroken = true; } | ||
| // A failure only breaks the guarantee while its post is still OPEN (approved + | ||
| // unposted). A relic entry for a post that has since posted (e.g. fired locally, or | ||
| // reconciled in an earlier era) must not hold the dot red forever - reconcile only | ||
| // clears entries when IT patches the post, so stale keys can linger in state.json. | ||
| const failedCount = Object.entries(s.cloudFailures || {}) | ||
| .filter(([key, f]) => f && CLOUD_LANES.includes(f.lane) && openKeys.has(key)).length; | ||
| // Red reasons in severity order - the FIRST one is the headline the popover shows. | ||
| const reason = contactStale ? 'cloud_unreachable' | ||
| : overdueCount > 0 ? 'overdue_unpublished' | ||
| : failedCount > 0 ? 'cloud_failures' | ||
| : (sub && sub.syncStopped) ? 'sync_stopped' | ||
| : (sub && sub.alwaysOn === false) ? 'flag_divergence' | ||
| : manifestBroken ? 'manifest_error' | ||
| : pendingCount > 0 ? 'push_pending' | ||
| : 'all_confirmed'; | ||
| const state = reason === 'all_confirmed' ? 'green' : reason === 'push_pending' ? 'yellow' : 'red'; | ||
| return { state, reason, pendingCount, failedCount, overdueCount, lastContactAt: okAtIso }; | ||
| } | ||
| // Reconcile EVERY always-on brand (the manual "Sync now" + the /api/cloud/reconcile | ||
@@ -784,0 +919,0 @@ // route), each inside its own client binding (the same pattern pushAlwaysOnBrands |
+10
-0
@@ -245,1 +245,11 @@ // cloud-config.mjs - the OPTIONAL managed-cloud configuration + the secret-tier | ||
| } | ||
| // Looser sibling of cloudEnabledForActive: is the install CONNECTED to a workspace with a | ||
| // usable api key, REGARDLESS of the active brand's always-on? The scheduler uses this to run | ||
| // the cloud READ-BACK + OFF-flag re-assert for a paused-but-connected brand, so a post the | ||
| // cloud already fired is seen locally (no double-post) and a once-failed best-effort pause | ||
| // self-heals. A genuinely always-on brand takes the cloudEnabledForActive() path; this is only | ||
| // reached after that path is ruled out, i.e. for a connected-but-paused brand. | ||
| export function cloudConnectedForActive() { | ||
| return getConnection().connected && Boolean(cloudApiKey()); | ||
| } |
+31
-1
@@ -45,2 +45,5 @@ // config.mjs - the pendpost "Settings / Connections" surface. | ||
| ytHandle: 'YT_HANDLE', | ||
| // Reddit target subreddit + Pinterest target board are operator-set identifiers. | ||
| redditSubreddit: 'REDDIT_SUBREDDIT', | ||
| pinterestBoardId: 'PINTEREST_BOARD_ID', | ||
| }; | ||
@@ -87,2 +90,4 @@ | ||
| ytHandle: readEnv('YT_HANDLE') || '', | ||
| redditSubreddit: readEnv('REDDIT_SUBREDDIT') || '', | ||
| pinterestBoardId: readEnv('PINTEREST_BOARD_ID') || '', | ||
| }; | ||
@@ -100,2 +105,4 @@ } | ||
| const xExp = Number(readEnv('X_TOKEN_EXPIRES_AT') || 0) || null; | ||
| const pinExp = Number(readEnv('PINTEREST_TOKEN_EXPIRES_AT') || 0) || null; | ||
| const ttExp = Number(readEnv('TIKTOK_TOKEN_EXPIRES_AT') || 0) || null; | ||
| return { | ||
@@ -117,2 +124,16 @@ metaPageToken: secret('META_PAGE_TOKEN', 'non-expiring page token'), | ||
| xAccessTokenSecret: secret('X_ACCESS_TOKEN_SECRET'), | ||
| // Static-credential lanes: a Telegram bot token, a Discord channel webhook URL. | ||
| telegramBotToken: secret('TELEGRAM_BOT_TOKEN', 'static bot token'), | ||
| discordWebhookUrl: secret('DISCORD_WEBHOOK_URL', 'static webhook URL'), | ||
| // Reddit: a script-app secret + the posting account password (password grant). | ||
| redditClientSecret: secret('REDDIT_CLIENT_SECRET'), | ||
| redditPassword: secret('REDDIT_PASSWORD', 'posting account password'), | ||
| // Pinterest: app secret + the rotating OAuth tokens. | ||
| pinterestAppSecret: secret('PINTEREST_APP_SECRET'), | ||
| pinterestAccessToken: { ...secret('PINTEREST_ACCESS_TOKEN', 'short-lived access token'), expiresAt: pinExp ? new Date(pinExp).toISOString() : null }, | ||
| pinterestRefreshToken: secret('PINTEREST_REFRESH_TOKEN', 'rotating refresh token'), | ||
| // TikTok: client secret + the rotating OAuth tokens. | ||
| tiktokClientSecret: secret('TIKTOK_CLIENT_SECRET'), | ||
| tiktokAccessToken: { ...secret('TIKTOK_ACCESS_TOKEN', 'short-lived access token'), expiresAt: ttExp ? new Date(ttExp).toISOString() : null }, | ||
| tiktokRefreshToken: secret('TIKTOK_REFRESH_TOKEN', 'rotating refresh token'), | ||
| }; | ||
@@ -129,2 +150,6 @@ } | ||
| 'X_API_SECRET', 'X_ACCESS_TOKEN_SECRET', | ||
| 'TELEGRAM_BOT_TOKEN', 'DISCORD_WEBHOOK_URL', | ||
| 'REDDIT_CLIENT_SECRET', 'REDDIT_PASSWORD', | ||
| 'PINTEREST_APP_SECRET', 'PINTEREST_ACCESS_TOKEN', 'PINTEREST_REFRESH_TOKEN', | ||
| 'TIKTOK_CLIENT_SECRET', 'TIKTOK_ACCESS_TOKEN', 'TIKTOK_REFRESH_TOKEN', | ||
| ]; | ||
@@ -144,2 +169,7 @@ | ||
| youtube: ['YT_REFRESH_TOKEN', 'YT_CLIENT_ID', 'YT_CLIENT_SECRET', 'YT_CHANNEL_ID', 'YT_HANDLE', 'YT_REDIRECT_URI'], | ||
| telegram: ['TELEGRAM_BOT_TOKEN', 'TELEGRAM_CHANNEL_ID'], | ||
| discord: ['DISCORD_WEBHOOK_URL'], | ||
| reddit: ['REDDIT_CLIENT_ID', 'REDDIT_CLIENT_SECRET', 'REDDIT_USERNAME', 'REDDIT_PASSWORD', 'REDDIT_SUBREDDIT'], | ||
| pinterest: ['PINTEREST_APP_ID', 'PINTEREST_APP_SECRET', 'PINTEREST_ACCESS_TOKEN', 'PINTEREST_REFRESH_TOKEN', 'PINTEREST_TOKEN_EXPIRES_AT', 'PINTEREST_BOARD_ID'], | ||
| tiktok: ['TIKTOK_CLIENT_KEY', 'TIKTOK_CLIENT_SECRET', 'TIKTOK_ACCESS_TOKEN', 'TIKTOK_REFRESH_TOKEN', 'TIKTOK_TOKEN_EXPIRES_AT', 'TIKTOK_REDIRECT_URI'], | ||
| }; | ||
@@ -157,3 +187,3 @@ | ||
| const keys = PLATFORM_ENV_KEYS[platform]; | ||
| if (!keys) return errorBody('invalid_input', `unknown platform "${platform}" (expected meta | linkedin | x | youtube)`); | ||
| if (!keys) return errorBody('invalid_input', `unknown platform "${platform}" (expected meta | linkedin | x | youtube | telegram | discord | reddit | pinterest | tiktok)`); | ||
| if (confirm !== true) { | ||
@@ -160,0 +190,0 @@ return errorBody('needs_confirm', `disconnect clears ALL stored credentials for ${platform} - pass confirm: true (you will need to re-authorize to post again).`); |
@@ -47,2 +47,7 @@ // interface.mjs - the PlatformDriver contract. | ||
| 'x-social.mjs': 'x', | ||
| 'telegram-social.mjs': 'telegram', | ||
| 'discord-social.mjs': 'discord', | ||
| 'reddit-social.mjs': 'reddit', | ||
| 'pinterest-social.mjs': 'pinterest', | ||
| 'tiktok-social.mjs': 'tiktok', | ||
| }; | ||
@@ -82,5 +87,10 @@ | ||
| x: { script: 'scripts/x-social.mjs', platforms: ['x'], credentialEnvKeys: [], builtin: true }, | ||
| telegram: { script: 'scripts/telegram-social.mjs', platforms: ['telegram'], credentialEnvKeys: [], builtin: true }, | ||
| discord: { script: 'scripts/discord-social.mjs', platforms: ['discord'], credentialEnvKeys: [], builtin: true }, | ||
| reddit: { script: 'scripts/reddit-social.mjs', platforms: ['reddit'], credentialEnvKeys: [], builtin: true }, | ||
| pinterest: { script: 'scripts/pinterest-social.mjs', platforms: ['pinterest'], credentialEnvKeys: [], builtin: true }, | ||
| tiktok: { script: 'scripts/tiktok-social.mjs', platforms: ['tiktok'], credentialEnvKeys: [], builtin: true }, | ||
| }; | ||
| const BUILTIN_PLATFORMS = ['facebook', 'instagram', 'linkedin', 'youtube', 'x']; | ||
| const BUILTIN_PLATFORMS = ['facebook', 'instagram', 'linkedin', 'youtube', 'x', 'telegram', 'discord', 'reddit', 'pinterest', 'tiktok']; | ||
@@ -87,0 +97,0 @@ // One valid registry entry, or null when it is shaped wrong (skip-with-log, never |
@@ -51,2 +51,7 @@ // mock-driver.mjs - the credential-free driver. It implements the same envelope | ||
| if (platform === 'x') return { impressions: n(700, 6000), likes: n(20, 400), comments: n(1, 40), shares: n(1, 50), bookmarks: n(0, 60) }; | ||
| if (platform === 'telegram') return { views: n(100, 2000), forwards: n(0, 100), reactions: n(0, 50) }; | ||
| if (platform === 'discord') return { reactions: n(0, 100), replies: n(0, 50) }; | ||
| if (platform === 'reddit') return { upvotes: n(0, 2000), comments: n(0, 200), score: n(0, 1800) }; | ||
| if (platform === 'pinterest') return { impressions: n(100, 8000), saves: n(0, 400), clicks: n(0, 300) }; | ||
| if (platform === 'tiktok') return { views: n(500, 50000), likes: n(20, 4000), comments: n(0, 300), shares: n(0, 500) }; | ||
| return { views: n(100, 1000) }; | ||
@@ -66,2 +71,7 @@ } | ||
| if (platform === 'x') return !post.xPostId; | ||
| if (platform === 'telegram') return !post.tgMessageId; | ||
| if (platform === 'discord') return !post.dcMessageId; | ||
| if (platform === 'reddit') return !post.redditPostId; | ||
| if (platform === 'pinterest') return !post.pinId; | ||
| if (platform === 'tiktok') return !post.tiktokVideoId; | ||
| return false; | ||
@@ -85,2 +95,7 @@ } | ||
| if (platform === 'x') return due <= now && platforms.includes('x') && !post.xPostId; | ||
| if (platform === 'telegram') return due <= now && platforms.includes('telegram') && !post.tgMessageId; | ||
| if (platform === 'discord') return due <= now && platforms.includes('discord') && !post.dcMessageId; | ||
| if (platform === 'reddit') return due <= now && platforms.includes('reddit') && !post.redditPostId; | ||
| if (platform === 'pinterest') return due <= now && platforms.includes('pinterest') && !post.pinId; | ||
| if (platform === 'tiktok') return due <= now && platforms.includes('tiktok') && !post.tiktokVideoId; | ||
| return false; | ||
@@ -99,2 +114,12 @@ } | ||
| if (platforms.includes('x') && !post.xPostId) { post.xPostId = mockId('x'); results.push({ platform: 'x', action: 'publish', ok: true }); } | ||
| } else if (platform === 'telegram') { | ||
| if (platforms.includes('telegram') && !post.tgMessageId) { post.tgMessageId = mockId('tg'); results.push({ platform: 'telegram', action: 'publish', ok: true }); } | ||
| } else if (platform === 'discord') { | ||
| if (platforms.includes('discord') && !post.dcMessageId) { post.dcMessageId = mockId('dc'); results.push({ platform: 'discord', action: 'publish', ok: true }); } | ||
| } else if (platform === 'reddit') { | ||
| if (platforms.includes('reddit') && !post.redditPostId) { post.redditPostId = mockId('rd'); results.push({ platform: 'reddit', action: 'publish', ok: true }); } | ||
| } else if (platform === 'pinterest') { | ||
| if (platforms.includes('pinterest') && !post.pinId) { post.pinId = mockId('pin'); results.push({ platform: 'pinterest', action: 'publish', ok: true }); } | ||
| } else if (platform === 'tiktok') { | ||
| if (platforms.includes('tiktok') && !post.tiktokVideoId) { post.tiktokVideoId = mockId('tt'); results.push({ platform: 'tiktok', action: 'publish', ok: true }); } | ||
| } else if (platform === 'youtube') { | ||
@@ -156,2 +181,12 @@ if (platforms.includes('youtube') && !post.ytVideoId) { | ||
| if (post.xPostId) results.push({ postId: post.id, platform: 'x', action: 'insights', ok: true, metrics: metricsFor('x', post.id) }); | ||
| } else if (platform === 'telegram') { | ||
| if (post.tgMessageId) results.push({ postId: post.id, platform: 'telegram', action: 'insights', ok: true, metrics: metricsFor('telegram', post.id) }); | ||
| } else if (platform === 'discord') { | ||
| if (post.dcMessageId) results.push({ postId: post.id, platform: 'discord', action: 'insights', ok: true, metrics: metricsFor('discord', post.id) }); | ||
| } else if (platform === 'reddit') { | ||
| if (post.redditPostId) results.push({ postId: post.id, platform: 'reddit', action: 'insights', ok: true, metrics: metricsFor('reddit', post.id) }); | ||
| } else if (platform === 'pinterest') { | ||
| if (post.pinId) results.push({ postId: post.id, platform: 'pinterest', action: 'insights', ok: true, metrics: metricsFor('pinterest', post.id) }); | ||
| } else if (platform === 'tiktok') { | ||
| if (post.tiktokVideoId) results.push({ postId: post.id, platform: 'tiktok', action: 'insights', ok: true, metrics: metricsFor('tiktok', post.id) }); | ||
| } else if (platform === 'youtube') { | ||
@@ -164,2 +199,15 @@ if (post.ytVideoId) results.push({ postId: post.id, platform: 'youtube', action: 'insights', ok: true, metrics: metricsFor('youtube', post.id) }); | ||
| // Mock release (make-public recovery): a mock youtube post with a fabricated id | ||
| // flips 'public' with no network. Mirrors the engine `release` envelope shape. | ||
| function handleRelease(platform, planPath, only) { | ||
| const plan = loadPlan(planPath); | ||
| const posts = (plan.posts || []).filter((p) => (only ? p.id === only : true)); | ||
| const results = []; | ||
| for (const post of posts) { | ||
| if (platform !== 'youtube' || !(post.platforms || []).includes('youtube') || !post.ytVideoId) continue; | ||
| results.push({ postId: post.id, platform: 'youtube', action: 'release', ok: true, id: post.ytVideoId, live: true, state: 'public', permalink: `https://youtu.be/${post.ytVideoId}` }); | ||
| } | ||
| return { ok: true, results }; | ||
| } | ||
| // Mock read-back: a post that already carries a fabricated id reads as LIVE, | ||
@@ -172,3 +220,3 @@ // so the full mock loop (publish -> verify) lands a verified-live post with no | ||
| const results = []; | ||
| const liveState = { instagram: 'published', facebook: 'published', youtube: 'public', linkedin: 'published', x: 'published' }; | ||
| const liveState = { instagram: 'published', facebook: 'published', youtube: 'public', linkedin: 'published', x: 'published', telegram: 'sent', discord: 'posted', reddit: 'posted', pinterest: 'published', tiktok: 'published' }; | ||
| const permalinkFor = (p, post) => { | ||
@@ -178,2 +226,7 @@ if (p === 'youtube') return `https://youtu.be/${post.ytVideoId}`; | ||
| if (p === 'x') return `https://x.com/i/web/status/${post.xPostId}`; | ||
| if (p === 'telegram') return `https://t.me/mockchannel/${post.tgMessageId}`; | ||
| if (p === 'discord') return `https://discord.com/channels/mock/mock/${post.dcMessageId}`; | ||
| if (p === 'reddit') return `https://www.reddit.com/comments/${post.redditPostId}/`; | ||
| if (p === 'pinterest') return `https://www.pinterest.com/pin/${post.pinId}/`; | ||
| if (p === 'tiktok') return `https://www.tiktok.com/@mockcreator/video/${post.tiktokVideoId}`; | ||
| return `https://example.invalid/mock/${p}/${post.id}`; | ||
@@ -183,3 +236,3 @@ }; | ||
| const targeted = post.platforms || []; | ||
| const has = { instagram: post.igMediaId, facebook: post.fbReelId || post.fbPostId, youtube: post.ytVideoId, linkedin: post.liPostId, x: post.xPostId }; | ||
| const has = { instagram: post.igMediaId, facebook: post.fbReelId || post.fbPostId, youtube: post.ytVideoId, linkedin: post.liPostId, x: post.xPostId, telegram: post.tgMessageId, discord: post.dcMessageId, reddit: post.redditPostId, pinterest: post.pinId, tiktok: post.tiktokVideoId }; | ||
| if (platform === 'meta') { | ||
@@ -214,2 +267,6 @@ for (const p of ['instagram', 'facebook']) { | ||
| return { ok: true, results: [{ platform, action: 'profile-update', ok: true, detail: 'mock profile updated (no live X call)' }] }; | ||
| case 'release': | ||
| // Mock release: a mock youtube post always verifies 'public' (no real | ||
| // privacy), so a release is just a successful no-op acknowledgement. | ||
| return planPath ? handleRelease(platform, planPath, only) : { ok: true, results: [] }; | ||
| case 'verify': | ||
@@ -216,0 +273,0 @@ return planPath ? handleVerify(platform, planPath, only) : { ok: true, results: [] }; |
+5
-0
@@ -22,2 +22,7 @@ // health.mjs - live per-platform liveness probes for the pendpost health bar. | ||
| x: 'scripts/x-social.mjs', | ||
| telegram: 'scripts/telegram-social.mjs', | ||
| discord: 'scripts/discord-social.mjs', | ||
| reddit: 'scripts/reddit-social.mjs', | ||
| pinterest: 'scripts/pinterest-social.mjs', | ||
| tiktok: 'scripts/tiktok-social.mjs', | ||
| }; | ||
@@ -24,0 +29,0 @@ |
+1
-1
@@ -31,3 +31,3 @@ // mode.mjs - decides whether a platform lane runs LIVE (real API calls) or MOCK | ||
| export const MOCKABLE_COMMANDS = new Set([ | ||
| 'schedule', 'publish-due', 'publish', 'fbreel', 'set-thumbnail', | ||
| 'schedule', 'release', 'publish-due', 'publish', 'fbreel', 'set-thumbnail', | ||
| 'insights', 'verify', 'validate', 'delete', 'refresh', 'profile', | ||
@@ -34,0 +34,0 @@ ]); |
+22
-0
@@ -112,2 +112,7 @@ // plans.mjs - reads the campaign plan store. The plan JSON files written by the | ||
| if (platform === 'x') return !post.xPostId; | ||
| if (platform === 'telegram') return !post.tgMessageId; | ||
| if (platform === 'discord') return !post.dcMessageId; | ||
| if (platform === 'reddit') return !post.redditPostId; | ||
| if (platform === 'pinterest') return !post.pinId; | ||
| if (platform === 'tiktok') return !post.tiktokVideoId; | ||
| return false; | ||
@@ -180,2 +185,14 @@ } | ||
| x: post.xPostId ? `https://x.com/i/web/status/${post.xPostId}` : null, | ||
| // Telegram/Discord deep links need the channel/guild identity (not on the post), | ||
| // so the authoritative link comes from post.verify.platforms[p].permalink (the | ||
| // engine reads it back); no slug is derivable from the message id alone here. | ||
| telegram: null, | ||
| discord: null, | ||
| // Reddit: a fullname post id (t3_...) yields a canonical comments link. | ||
| reddit: post.redditPostId ? `https://www.reddit.com/comments/${String(post.redditPostId).replace(/^t3_/, '')}/` : null, | ||
| // Pinterest: a pin id is a self-contained permalink. | ||
| pinterest: post.pinId ? `https://www.pinterest.com/pin/${post.pinId}/` : null, | ||
| // TikTok: the watch URL needs the creator's @username (account identity, not on | ||
| // the post), so the authoritative link comes from the engine's verify read-back. | ||
| tiktok: null, | ||
| }; | ||
@@ -258,2 +275,7 @@ } | ||
| xPostId: post.xPostId || null, | ||
| tgMessageId: post.tgMessageId || null, | ||
| dcMessageId: post.dcMessageId || null, | ||
| redditPostId: post.redditPostId || null, | ||
| pinId: post.pinId || null, | ||
| tiktokVideoId: post.tiktokVideoId || null, | ||
| }, | ||
@@ -260,0 +282,0 @@ postedAt: post.postedAt || null, |
+264
-0
@@ -261,2 +261,266 @@ // playbooks.mjs - the PROSE source of truth for per-platform vendor onboarding: | ||
| }, | ||
| telegram: { | ||
| portalUrl: 'https://t.me/BotFather', | ||
| appToCreate: 'a Bot (via @BotFather) that posts to your channel', | ||
| productsToAdd: [], | ||
| scopes: [], | ||
| steps: [ | ||
| { | ||
| title: 'Create a bot with BotFather', | ||
| detail: | ||
| 'Open Telegram, message @BotFather, send /newbot, and follow the prompts. ' + | ||
| 'BotFather hands you an HTTP API token - that is the only credential pendpost needs.', | ||
| env: 'TELEGRAM_BOT_TOKEN', | ||
| }, | ||
| { | ||
| title: 'Create the destination channel', | ||
| detail: | ||
| 'Create the channel you want to post to. A public channel with an @username gives clean post permalinks; ' + | ||
| 'a private channel works too but has no shareable links. Record its @username (or numeric chat id).', | ||
| env: 'TELEGRAM_CHANNEL_ID', | ||
| }, | ||
| { | ||
| title: 'Add the bot as a channel admin', | ||
| detail: | ||
| 'In the channel, add the bot as an administrator and grant it "Post Messages". ' + | ||
| 'Without admin rights the bot cannot publish.', | ||
| }, | ||
| { | ||
| title: 'Confirm the connection', | ||
| detail: | ||
| 'Run the CLI below to verify the token authenticates and the bot can reach the channel. ' + | ||
| 'There is no token exchange - the bot token is static.', | ||
| cli: 'node scripts/telegram-social.mjs auth', | ||
| }, | ||
| ], | ||
| commonFailures: [ | ||
| { | ||
| symptom: 'Publishing returns "Unauthorized".', | ||
| cause: 'The bot token is wrong or was revoked.', | ||
| fix: 'Get a fresh token from @BotFather (/token), update TELEGRAM_BOT_TOKEN, and re-run the auth check.', | ||
| }, | ||
| { | ||
| symptom: 'Publishing returns "chat not found" or "not enough rights".', | ||
| cause: 'The channel id is wrong, or the bot is not an admin with Post Messages.', | ||
| fix: 'Confirm TELEGRAM_CHANNEL_ID and add the bot as a channel administrator with posting rights.', | ||
| }, | ||
| ], | ||
| }, | ||
| discord: { | ||
| portalUrl: 'https://support.discord.com/hc/en-us/articles/228383668-Intro-to-Webhooks', | ||
| appToCreate: 'an Incoming Webhook on the target channel', | ||
| productsToAdd: [], | ||
| scopes: [], | ||
| steps: [ | ||
| { | ||
| title: 'Create a channel webhook', | ||
| detail: | ||
| 'In your Discord server, open the target channel\'s settings -> Integrations -> Webhooks -> New Webhook. ' + | ||
| 'Name it (e.g. pendpost) and pick the channel it posts to.', | ||
| }, | ||
| { | ||
| title: 'Copy the full webhook URL', | ||
| detail: | ||
| 'Click "Copy Webhook URL" and copy the ENTIRE value - it ends in a long token. ' + | ||
| 'A truncated URL fails with "Invalid Webhook Token".', | ||
| env: 'DISCORD_WEBHOOK_URL', | ||
| }, | ||
| { | ||
| title: 'Confirm the connection', | ||
| detail: | ||
| 'Run the CLI below to verify the webhook resolves. There is no OAuth and no token to mint - the URL is the credential.', | ||
| cli: 'node scripts/discord-social.mjs auth', | ||
| }, | ||
| ], | ||
| commonFailures: [ | ||
| { | ||
| symptom: 'Publishing returns "Invalid Webhook Token".', | ||
| cause: 'The webhook URL was copied incompletely, or the webhook was deleted.', | ||
| fix: 'Recreate or re-copy the full webhook URL (Channel -> Integrations -> Webhooks), update DISCORD_WEBHOOK_URL, and re-run the auth check.', | ||
| }, | ||
| { | ||
| symptom: 'Posts 404 after working before.', | ||
| cause: 'The webhook (or its channel) was deleted in Discord.', | ||
| fix: 'Create a new webhook on the channel and update DISCORD_WEBHOOK_URL.', | ||
| }, | ||
| ], | ||
| }, | ||
| reddit: { | ||
| portalUrl: 'https://www.reddit.com/prefs/apps', | ||
| appToCreate: 'a "script" type app under your Reddit account', | ||
| productsToAdd: [], | ||
| scopes: ['submit', 'identity'], | ||
| steps: [ | ||
| { | ||
| title: 'Create a script app', | ||
| detail: | ||
| 'Sign in to Reddit, open the apps page, and click "create another app". ' + | ||
| 'Choose the "script" type, give it a name, and set the redirect uri to http://localhost:8088 (unused by a script app but required). ' + | ||
| 'The id under the app name is your client id; the "secret" field is your client secret.', | ||
| env: 'REDDIT_CLIENT_ID', | ||
| }, | ||
| { | ||
| title: 'Record the client secret', | ||
| detail: 'Copy the secret shown next to the app and store it. Treat it like a password.', | ||
| env: 'REDDIT_CLIENT_SECRET', | ||
| }, | ||
| { | ||
| title: 'Set the posting account credentials', | ||
| detail: | ||
| 'A script app authenticates as the Reddit account that owns it, using a password grant. ' + | ||
| 'Set the username and password of that account. Use a dedicated posting account, not a shared admin login.', | ||
| env: 'REDDIT_USERNAME', | ||
| }, | ||
| { | ||
| title: 'Pick the target subreddit', | ||
| detail: | ||
| 'Set the subreddit you publish to (without the r/ prefix). The account must have permission to post there, ' + | ||
| 'and the subreddit rules must allow the kind of content you send.', | ||
| env: 'REDDIT_SUBREDDIT', | ||
| }, | ||
| { | ||
| title: 'Confirm the connection', | ||
| detail: | ||
| 'Run the CLI below. It mints a short-lived token with the password grant and reads your identity back. ' + | ||
| 'Note the API free tier is non-commercial and rate limited; high-volume or commercial use needs an approved plan.', | ||
| cli: 'node scripts/reddit-social.mjs auth', | ||
| }, | ||
| ], | ||
| commonFailures: [ | ||
| { | ||
| symptom: 'auth returns "401 Unauthorized".', | ||
| cause: 'The client id/secret pair is wrong, or the app is not a "script" type.', | ||
| fix: 'Recreate the app as a "script" type and copy the id (under the name) and secret exactly into REDDIT_CLIENT_ID / REDDIT_CLIENT_SECRET.', | ||
| }, | ||
| { | ||
| symptom: 'auth returns "invalid_grant".', | ||
| cause: 'The account username or password is wrong, or the account has two-factor login enabled.', | ||
| fix: 'Check REDDIT_USERNAME / REDDIT_PASSWORD. Password grant does not support 2FA accounts; use a posting account without 2FA.', | ||
| }, | ||
| { | ||
| symptom: 'Publishing returns "SUBREDDIT_NOTALLOWED" or a rules error.', | ||
| cause: 'The account cannot post to that subreddit, or the content breaks subreddit rules.', | ||
| fix: 'Confirm REDDIT_SUBREDDIT, the account has posting access there, and the post matches the subreddit rules.', | ||
| }, | ||
| ], | ||
| }, | ||
| pinterest: { | ||
| portalUrl: 'https://developers.pinterest.com/apps/', | ||
| appToCreate: 'a Pinterest developer app with the v5 API enabled', | ||
| productsToAdd: [], | ||
| scopes: ['boards:read', 'pins:read', 'pins:write'], | ||
| steps: [ | ||
| { | ||
| title: 'Create a developer app', | ||
| detail: | ||
| 'Open the Pinterest developer portal and create an app. New apps start with Trial access, ' + | ||
| 'which can post only to the app owner\'s own account; Standard access (a separate review) is needed for broader use. ' + | ||
| 'Trial access is enough to publish to your own boards.', | ||
| env: 'PINTEREST_APP_ID', | ||
| }, | ||
| { | ||
| title: 'Record the app secret', | ||
| detail: 'Copy the app secret from the app settings and store it. It is used as HTTP Basic auth on the token endpoint.', | ||
| env: 'PINTEREST_APP_SECRET', | ||
| }, | ||
| { | ||
| title: 'Add the redirect uri', | ||
| detail: | ||
| 'In the app settings, add the redirect uri exactly: http://127.0.0.1:8088/oauth/pinterest/callback . ' + | ||
| 'The auth command runs a local loopback server on that address to receive the authorization code.', | ||
| }, | ||
| { | ||
| title: 'Pick the target board', | ||
| detail: | ||
| 'Choose the board pins are published to and record its id. The auth command can list your boards after you connect.', | ||
| env: 'PINTEREST_BOARD_ID', | ||
| }, | ||
| { | ||
| title: 'Authorize', | ||
| detail: | ||
| 'Run the CLI below. It opens the consent screen, exchanges the code for an access + refresh token, ' + | ||
| 'and stores the expiry. The access token is short-lived and is refreshed automatically before it expires.', | ||
| cli: 'node scripts/pinterest-social.mjs auth', | ||
| }, | ||
| ], | ||
| commonFailures: [ | ||
| { | ||
| symptom: 'The consent screen rejects the redirect uri.', | ||
| cause: 'The redirect uri in the app settings does not match the loopback address exactly.', | ||
| fix: 'Add http://127.0.0.1:8088/oauth/pinterest/callback to the app, with no trailing slash difference, and retry.', | ||
| }, | ||
| { | ||
| symptom: 'Publishing returns a 403 about access level.', | ||
| cause: 'The app is in Trial access and is targeting an account other than the app owner.', | ||
| fix: 'Publish to the app owner\'s own boards while in Trial, or apply for Standard access to post more broadly.', | ||
| }, | ||
| { | ||
| symptom: 'Posting fails with an expired-token error.', | ||
| cause: 'The stored refresh token was revoked or rotated out.', | ||
| fix: 'Re-run the auth command to mint a fresh access + refresh token pair.', | ||
| }, | ||
| ], | ||
| }, | ||
| tiktok: { | ||
| portalUrl: 'https://developers.tiktok.com/apps/', | ||
| appToCreate: 'a TikTok developer app with Login Kit and the Content Posting API', | ||
| productsToAdd: ['Login Kit', 'Content Posting API'], | ||
| scopes: ['user.info.basic', 'video.upload', 'video.publish'], | ||
| steps: [ | ||
| { | ||
| title: 'Create a developer app', | ||
| detail: | ||
| 'Open the TikTok developer portal and create an app. Add the Login Kit and Content Posting API products. ' + | ||
| 'Copy the client key and client secret from the app credentials.', | ||
| env: 'TIKTOK_CLIENT_KEY', | ||
| }, | ||
| { | ||
| title: 'Record the client secret', | ||
| detail: 'Copy the client secret and store it. It is used on the token exchange and refresh calls.', | ||
| env: 'TIKTOK_CLIENT_SECRET', | ||
| }, | ||
| { | ||
| title: 'Add the redirect uri', | ||
| detail: | ||
| 'In the app settings, register the redirect uri exactly: http://127.0.0.1:8088/oauth/tiktok/callback . ' + | ||
| 'The auth command runs a local loopback server there to receive the authorization code.', | ||
| env: 'TIKTOK_REDIRECT_URI', | ||
| }, | ||
| { | ||
| title: 'Authorize', | ||
| detail: | ||
| 'Run the CLI below. It opens the consent screen for the user.info.basic, video.upload, and video.publish scopes, ' + | ||
| 'exchanges the code, and stores the access + refresh tokens with their expiry. The access token is refreshed automatically.', | ||
| cli: 'node scripts/tiktok-social.mjs auth', | ||
| }, | ||
| { | ||
| title: 'Note the audit gate', | ||
| detail: | ||
| 'Until your app passes TikTok content-posting audit, posts are restricted to SELF_ONLY (visible only to the posting account). ' + | ||
| 'Submit the app for audit to publish publicly.', | ||
| }, | ||
| ], | ||
| commonFailures: [ | ||
| { | ||
| symptom: 'The consent screen rejects the redirect uri.', | ||
| cause: 'The redirect uri in the app settings does not match the loopback address used by the auth command.', | ||
| fix: 'Register http://127.0.0.1:8088/oauth/tiktok/callback in the app and set TIKTOK_REDIRECT_URI to the same value.', | ||
| }, | ||
| { | ||
| symptom: 'A scope error during authorization.', | ||
| cause: 'The app does not have the Content Posting API or the requested scopes approved.', | ||
| fix: 'Add the Content Posting API product and request the video.upload and video.publish scopes for the app.', | ||
| }, | ||
| { | ||
| symptom: 'Posts publish but are not publicly visible.', | ||
| cause: 'The app has not passed content-posting audit, so posts are forced to SELF_ONLY.', | ||
| fix: 'Submit the app for TikTok audit; until then expect SELF_ONLY visibility.', | ||
| }, | ||
| ], | ||
| }, | ||
| }; |
@@ -31,3 +31,3 @@ // publish-job.mjs - the cloud-ready publish-job seam (PURE, no I/O). | ||
| // silently described. | ||
| const KNOWN_LANES = new Set(['meta', 'linkedin', 'x', 'youtube', 'bluesky']); | ||
| const KNOWN_LANES = new Set(['meta', 'linkedin', 'x', 'youtube', 'youtube-release', 'bluesky', 'telegram', 'discord', 'reddit', 'pinterest', 'tiktok']); | ||
@@ -144,2 +144,7 @@ export class PublishJobError extends Error { | ||
| xPostId: ids.xPostId || null, | ||
| tgMessageId: ids.tgMessageId || null, | ||
| dcMessageId: ids.dcMessageId || null, | ||
| redditPostId: ids.redditPostId || null, | ||
| pinId: ids.pinId || null, | ||
| tiktokVideoId: ids.tiktokVideoId || null, | ||
| }), | ||
@@ -146,0 +151,0 @@ }), |
+158
-28
@@ -23,3 +23,3 @@ // scheduler.mjs - the in-process publish scheduler (Phase B). | ||
| import { getPosting } from './config.mjs'; | ||
| import { cloudEnabledForActive } from './cloud-config.mjs'; | ||
| import { cloudEnabledForActive, cloudConnectedForActive } from './cloud-config.mjs'; | ||
@@ -37,4 +37,33 @@ const TICK_MS = 60_000; | ||
| youtube: { script: 'scripts/yt-social.mjs', command: 'schedule', timeoutMs: 600_000 }, | ||
| // Recovery lane: flip a private-overdue scheduled video public (no re-upload). | ||
| // LOCAL-ONLY BY DESIGN, not a pending TODO: release hits the same CASA-gated YouTube | ||
| // Data API as the youtube lane, which the managed cloud is gated out of (pendpost-cloud | ||
| // enabled-platforms.ts). Owed by lanesFor, never the shared lanesOwed, so the cloud push | ||
| // contract is untouched; a private-overdue video is recovered on the next LOCAL tick. | ||
| // Cheap (one videos.update), so a tight timeout. | ||
| 'youtube-release': { script: 'scripts/yt-social.mjs', command: 'release', timeoutMs: 120_000 }, | ||
| telegram: { script: 'scripts/telegram-social.mjs', command: 'publish-due', timeoutMs: 180_000 }, | ||
| discord: { script: 'scripts/discord-social.mjs', command: 'publish-due', timeoutMs: 180_000 }, | ||
| reddit: { script: 'scripts/reddit-social.mjs', command: 'publish-due', timeoutMs: 180_000 }, | ||
| pinterest: { script: 'scripts/pinterest-social.mjs', command: 'publish-due', timeoutMs: 180_000 }, | ||
| tiktok: { script: 'scripts/tiktok-social.mjs', command: 'publish-due', timeoutMs: 180_000 }, | ||
| }; | ||
| // The lanes the managed cloud fires - the documented mirror of pendpost-cloud's | ||
| // ENABLED_LANES (packages/shared/src/enabled-platforms.ts); keep the two in sync. | ||
| // Every other lane (youtube + youtube-release, telegram, discord, reddit, pinterest, | ||
| // tiktok) is LOCAL-ONLY: the cloud never fires it, so a cloud-managed brand still | ||
| // runs those lanes on the normal local schedule (the old early-return stranded them). | ||
| export const CLOUD_LANES = Object.freeze(['meta', 'linkedin', 'x', 'bluesky']); | ||
| // Cloud-managed liveness backstop: a cloud-lane post overdue past this grace that the | ||
| // cloud has provably NOT fired (reconcile ran first; the post is still unposted) is | ||
| // fired LOCALLY, so an always-on brand can never silently miss a post again (the | ||
| // Jun/Jul incident: every signal green, zero posts fired). The grace anchors on | ||
| // max(scheduledAt, first push-ack) so a freshly-connected backlog gives the cloud its | ||
| // window before local steps in. DELIBERATELY above the cloud's 15-min staleness hold | ||
| // (pendpost-cloud worker contract) and the pendpost_health 10-min overdue alert: warn | ||
| // at 10, cloud stops auto-firing at 15, local backstop fires at 20 - never overlapping. | ||
| const CLOUD_BACKSTOP_GRACE_MS = 20 * 60_000; | ||
| let timer = null; // setInterval handle for the recurring publish tick | ||
@@ -147,2 +176,7 @@ let kickTimer = null; // one-shot initial tick shortly after start | ||
| if (on('youtube') && platforms.includes('youtube') && !post.ids.ytVideoId) owed.push('youtube'); | ||
| if (on('telegram') && platforms.includes('telegram') && !post.ids.tgMessageId) owed.push('telegram'); | ||
| if (on('discord') && platforms.includes('discord') && !post.ids.dcMessageId) owed.push('discord'); | ||
| if (on('reddit') && platforms.includes('reddit') && !post.ids.redditPostId) owed.push('reddit'); | ||
| if (on('pinterest') && platforms.includes('pinterest') && !post.ids.pinId) owed.push('pinterest'); | ||
| if (on('tiktok') && platforms.includes('tiktok') && !post.ids.tiktokVideoId) owed.push('tiktok'); | ||
| return owed; | ||
@@ -160,3 +194,17 @@ } | ||
| if (Number.isNaN(due)) return []; | ||
| return lanesOwed(post).filter((lane) => (lane === 'youtube' ? due > now : due <= now)); | ||
| const lanes = lanesOwed(post).filter((lane) => (lane === 'youtube' ? due > now : due <= now)); | ||
| // LOCAL recovery (not in the shared lanesOwed, so it never changes the cloud | ||
| // push contract): a natively-scheduled YouTube video YouTube left PRIVATE past | ||
| // its publishAt - the read-back (post.verify) says 'private-overdue'. Owed only | ||
| // once the id exists AND the verify state is private-overdue, so it can never | ||
| // race the upload lane (which fires only when !ytVideoId) or publish a video | ||
| // still legitimately scheduled for the future. | ||
| if (due <= now | ||
| && platformEnabled('youtube', getPosting()) | ||
| && (post.platforms || []).includes('youtube') | ||
| && post.ids?.ytVideoId | ||
| && post.verify?.platforms?.youtube?.state === 'private-overdue') { | ||
| lanes.push('youtube-release'); | ||
| } | ||
| return lanes; | ||
| } | ||
@@ -175,2 +223,8 @@ | ||
| youtube: ['youtube'], | ||
| 'youtube-release': ['youtube'], | ||
| telegram: ['telegram'], | ||
| discord: ['discord'], | ||
| reddit: ['reddit'], | ||
| pinterest: ['pinterest'], | ||
| tiktok: ['tiktok'], | ||
| }; | ||
@@ -265,18 +319,19 @@ export function lanePlatforms(lane, post) { | ||
| const now = Date.now(); | ||
| // Cloud-managed safeguard: if this client is connected to the cloud (cloud.enabled | ||
| // + a workspace id), the always-on cloud worker is the SOLE firer. Skip the LOCAL | ||
| // publish entirely to prevent a double-post - the local and cloud copies cannot see | ||
| // each other's minted ids. Disabling the cloud (or ejecting) resumes local firing. | ||
| if (cloudEnabledForActive()) { | ||
| // Cloud-managed contract (rewritten after the Jun/Jul silent-outage incident): the | ||
| // cloud is the PRIMARY firer for its lanes, but local NEVER blindly abdicates. | ||
| // - CLOUD_LANES: skip locally only while the cloud is provably handling them; a | ||
| // post overdue past CLOUD_BACKSTOP_GRACE_MS that reconcile still shows unposted | ||
| // is fired LOCALLY (the backstop below). | ||
| // - Every other lane is local-only by design (the cloud is gated out of it) and | ||
| // runs on the normal local schedule - the old early return stranded YouTube etc. | ||
| const cloudManaged = cloudEnabledForActive(); | ||
| let reconcile = null; | ||
| let push = null; | ||
| let remediate = null; | ||
| if (cloudManaged) { | ||
| const who = publishClientId(); | ||
| if (!cloudManagedLogged.has(who)) { | ||
| logLine('info', `scheduler: ${who} is cloud-managed - local publishing paused (the cloud runtime fires this workspace)`); | ||
| logLine('info', `scheduler: ${who} is cloud-managed - cloud fires ${CLOUD_LANES.join('/')}; local keeps the other lanes + the overdue backstop`); | ||
| cloudManagedLogged.add(who); | ||
| } | ||
| // Local PUBLISHING is the cloud's job, but the READ-BACK is ours: poll the cloud's | ||
| // terminal results and reconcile this client's plan (write the minted ids, clear | ||
| // the planner's "overdue"). Runs INSIDE the tick's per-client withClient binding, so | ||
| // the reconciler resolves to THIS client. Dynamic import avoids a scheduler<->cloud- | ||
| // client module cycle (same pattern tick() uses for insights/verify); a failure here | ||
| // never throws - firing is the cloud's responsibility, not this read-back. | ||
| // The local tick is the durable DRIVER. Order: reconcile (read terminal results: | ||
@@ -286,7 +341,5 @@ // mark done->posted, collect failures) -> push (idempotently (re)send every eligible | ||
| // claim guard makes a re-push safe) -> remediate (reseal + backed-off retrigger of the | ||
| // reconciled failures). Each step is best-effort and NEVER throws here (firing is the | ||
| // cloud's job). A dynamic import avoids a scheduler<->cloud-client cycle. | ||
| let reconcile = null; | ||
| let push = null; | ||
| let remediate = null; | ||
| // reconciled failures). Each step is best-effort and NEVER throws here. Reconcile MUST | ||
| // run before the walk below so a cloud-fired post is already 'posted' and the | ||
| // backstop stands down. A dynamic import avoids a scheduler<->cloud-client cycle. | ||
| try { | ||
@@ -302,2 +355,17 @@ const cc = await import('./cloud-client.mjs'); | ||
| const subView = await cc.getSubscription().catch(() => null); | ||
| if (subView) { | ||
| // Persist the slim view so cloudSyncStatus (the status dot) needs no network | ||
| // call, and self-heal a cloud-side flag drift: cloud says OFF while the local | ||
| // brand is ON -> re-assert (the mirror of the paused path's OFF re-assert). | ||
| try { | ||
| const state = loadState(); | ||
| state.cloudSubView = { alwaysOn: subView.alwaysOn !== false, syncStopped: Boolean(subView.syncStopped), stopReason: subView.stopReason || null, at: new Date().toISOString() }; | ||
| saveState(); | ||
| } catch { /* subView cache is best-effort */ } | ||
| if (subView.alwaysOn === false) { | ||
| logLine('warn', `scheduler: cloud reports ${who} NOT always-on while the local flag is ON - re-asserting`); | ||
| try { await cc.syncBrandFlags(); } | ||
| catch (e) { logLine('warn', `scheduler: cloud on-flag re-assert failed for ${who}: ${e.message}`); } | ||
| } | ||
| } | ||
| if (subView && subView.syncStopped) { | ||
@@ -314,12 +382,24 @@ logLine('warn', `scheduler: cloud sync stopped for ${who} (${subView.stopReason}); skipping push`); | ||
| } | ||
| // Heartbeat: stamp the tick time so "scheduler alive" is truthful for a cloud-managed | ||
| // client too (this branch used to return BEFORE the lastRun stamp in the local path). | ||
| try { | ||
| const state = loadState(); | ||
| state.scheduler = { ...(state.scheduler || {}), lastRun: new Date().toISOString() }; | ||
| saveState(); | ||
| } catch { /* heartbeat is best-effort */ } | ||
| return { ok: true, ran: [], code: 'cloud_managed', reconcile, push, remediate, message: 'local publishing paused; this workspace is fired by the cloud runtime' }; | ||
| } else { | ||
| cloudManagedLogged.delete(publishClientId()); | ||
| // Connected but this brand is NOT always-on. The cloud may still hold/fire jobs | ||
| // pushed while the brand WAS on (a disable is best-effort and does not drain the | ||
| // queue), so local firing alone would double-post. Fail-safe, both best-effort: | ||
| // (a) READ BACK the cloud's terminal results so a post the cloud already fired | ||
| // flips to posted HERE and the local walk below stands down; | ||
| // (b) RE-ASSERT this brand's OFF flag to the cloud so a once-failed pause self-heals. | ||
| // Must run BEFORE loadPlanStore so a freshly-reconciled 'posted' post is excluded this run. | ||
| if (cloudConnectedForActive()) { | ||
| const who = publishClientId(); | ||
| try { | ||
| const cc = await import('./cloud-client.mjs'); | ||
| try { await cc.reconcileCloudResults({}); } | ||
| catch (e) { logLine('warn', `scheduler: cloud read-back failed for ${who}: ${e.message}`); } | ||
| try { await cc.syncBrandFlags(); } | ||
| catch (e) { logLine('warn', `scheduler: cloud off-flag re-assert failed for ${who}: ${e.message}`); } | ||
| } catch (e) { | ||
| logLine('warn', `scheduler: cloud-client load failed for ${who}: ${e.message}`); | ||
| } | ||
| } | ||
| } | ||
| cloudManagedLogged.delete(publishClientId()); | ||
| const { campaigns, manifestError } = loadPlanStore(); | ||
@@ -354,2 +434,33 @@ if (manifestError) { | ||
| let lanes = lanesFor(post, now); | ||
| // Cloud-managed lane split + liveness backstop. Local-only lanes pass through on | ||
| // the normal schedule. A CLOUD lane is the cloud's job UNTIL the post is overdue | ||
| // past the grace (anchored on max(scheduledAt, first push-ack) so a freshly-pushed | ||
| // backlog gives the cloud its window) - then local fires it, because reconcile ran | ||
| // FIRST this tick and the post is provably still unposted. The dispatch below is | ||
| // the normal walk, so every local guard (Meta-368, cadence, brand-lint, inFlight) | ||
| // still applies to a backstop fire. | ||
| if (cloudManaged && lanes.length) { | ||
| const acks = loadState().cloudAccepted || {}; | ||
| const due = Date.parse(post.scheduledAt || ''); | ||
| const backstopLanes = []; | ||
| lanes = lanes.filter((lane) => { | ||
| if (!CLOUD_LANES.includes(lane)) return true; // local-only: normal schedule | ||
| if (Number.isNaN(due)) return false; | ||
| const ack = acks[`${c.id}:${post.id}:${lane}`]; | ||
| const ackMs = ack ? Date.parse(ack.at || '') : NaN; | ||
| const anchor = Number.isFinite(ackMs) ? Math.max(due, ackMs) : due; | ||
| if (now - anchor <= CLOUD_BACKSTOP_GRACE_MS) return false; // the cloud's window | ||
| backstopLanes.push(lane); | ||
| return true; | ||
| }); | ||
| for (const lane of backstopLanes) { | ||
| logLine('warn', `scheduler: cloud missed ${c.id}/${post.id} (${lane}) past the ${Math.round(CLOUD_BACKSTOP_GRACE_MS / 60000)}m grace - firing locally as backstop`); | ||
| appendActivity({ | ||
| campaign: c.id, postId: post.id, platform: lane, action: 'cloud-backstop', ok: true, | ||
| errorCode: null, | ||
| errorMessage: `cloud did not fire within ${Math.round(CLOUD_BACKSTOP_GRACE_MS / 60000)}m of due - publishing locally`, | ||
| lateMin: Number.isNaN(due) ? null : Math.max(0, Math.round((now - due) / 60000)), actor, | ||
| }); | ||
| } | ||
| } | ||
| if (metaBlocked && lanes.includes('meta')) { | ||
@@ -488,2 +599,16 @@ if (!metaSkipLogged) { | ||
| ran.push({ campaign: c.id, postId: post.id, lane, ok: !err && envelope?.ok !== false }); | ||
| // A successful release flipped the already-uploaded video public. Re-read | ||
| // it so post.verify leaves 'private-overdue' (-> verified-live), the post | ||
| // stops being owed a release next tick, and the planner drops it from the | ||
| // due list. verifySweep only re-checks 'fired-assumed', never 'verify- | ||
| // failed', so this explicit re-verify is what closes the loop. Dynamic | ||
| // import avoids a scheduler<->verify cycle (the pattern tick() uses). | ||
| if (lane === 'youtube-release' && !err && envelope?.ok !== false) { | ||
| try { | ||
| const { verifyPost } = await import('./verify.mjs'); | ||
| await verifyPost({ campaign: c.id, postId: post.id, actor }); | ||
| } catch (e) { | ||
| logLine('warn', `scheduler: post-release verify failed for ${c.id}/${post.id}: ${e.message}`); | ||
| } | ||
| } | ||
| } | ||
@@ -499,2 +624,7 @@ } finally { | ||
| saveState(); | ||
| // The cloud-managed shape keeps its code + driver summaries (API/tests key on it); | ||
| // `ran` now carries any local-only-lane or backstop fires instead of always []. | ||
| if (cloudManaged) { | ||
| return { ok: true, ran, code: 'cloud_managed', reconcile, push, remediate, message: `cloud fires ${CLOUD_LANES.join('/')}; local ran ${ran.length} job(s) (local-only lanes + overdue backstop)` }; | ||
| } | ||
| return { ok: true, ran }; | ||
@@ -501,0 +631,0 @@ } |
+37
-1
@@ -15,3 +15,3 @@ // setup.mjs - the single machine-readable SETUP-COMPLETENESS signal, read by BOTH | ||
| const PLATFORMS = ['meta', 'linkedin', 'x', 'youtube']; | ||
| const PLATFORMS = ['meta', 'linkedin', 'x', 'youtube', 'telegram', 'discord', 'reddit', 'pinterest', 'tiktok']; | ||
@@ -49,2 +49,35 @@ // Per-platform: label, the non-secret IDENTIFIERS an operator sets (config_set, | ||
| }, | ||
| telegram: { | ||
| label: 'Telegram', | ||
| identifiers: [], | ||
| secret: 'a Bot token + channel id', | ||
| connect: 'node scripts/telegram-social.mjs auth', | ||
| }, | ||
| discord: { | ||
| label: 'Discord', | ||
| identifiers: [], | ||
| secret: 'a channel webhook URL', | ||
| connect: 'node scripts/discord-social.mjs auth', | ||
| }, | ||
| reddit: { | ||
| label: 'Reddit', | ||
| beta: true, | ||
| identifiers: [{ key: 'redditSubreddit', acctField: 'subreddit', label: 'Subreddit', required: true }], | ||
| secret: 'Reddit app + account credentials', | ||
| connect: 'node scripts/reddit-social.mjs auth', | ||
| }, | ||
| pinterest: { | ||
| label: 'Pinterest', | ||
| beta: true, | ||
| identifiers: [{ key: 'pinterestBoardId', acctField: 'boardId', label: 'Pinterest Board ID', required: true }], | ||
| secret: 'a Pinterest OAuth token', | ||
| connect: 'node scripts/pinterest-social.mjs auth', | ||
| }, | ||
| tiktok: { | ||
| label: 'TikTok', | ||
| beta: true, | ||
| identifiers: [], | ||
| secret: 'a TikTok OAuth token', | ||
| connect: 'node scripts/tiktok-social.mjs auth', | ||
| }, | ||
| }; | ||
@@ -116,2 +149,5 @@ | ||
| label: def.label, | ||
| // BETA honesty surface: reddit/pinterest/tiktok are built but not yet | ||
| // live-proven, so the UI can badge them; live-verified lanes stay beta:false. | ||
| beta: Boolean(def.beta), | ||
| status, | ||
@@ -118,0 +154,0 @@ mode: acct.mode || 'mock', |
+1
-1
@@ -20,3 +20,3 @@ // util.mjs - shared helpers for the pendpost server (zero-dep). | ||
| export const DATA_ROOT = path.join(WORKSPACE_ROOT, 'data'); | ||
| export const VERSION = '1.1.1'; | ||
| export const VERSION = '1.2.0'; | ||
@@ -23,0 +23,0 @@ // The .env now lives in the ACTIVE client subtree, not at WORKSPACE_ROOT. |
+10
-0
@@ -27,2 +27,7 @@ // verify.mjs - publish read-back. Turns the guessed 'fired-assumed' (probably | ||
| x: 'scripts/x-social.mjs', | ||
| telegram: 'scripts/telegram-social.mjs', | ||
| discord: 'scripts/discord-social.mjs', | ||
| reddit: 'scripts/reddit-social.mjs', | ||
| pinterest: 'scripts/pinterest-social.mjs', | ||
| tiktok: 'scripts/tiktok-social.mjs', | ||
| }; | ||
@@ -44,2 +49,7 @@ | ||
| if (platforms.includes('x') && ids.xPostId) lanes.push('x'); | ||
| if (platforms.includes('telegram') && ids.tgMessageId) lanes.push('telegram'); | ||
| if (platforms.includes('discord') && ids.dcMessageId) lanes.push('discord'); | ||
| if (platforms.includes('reddit') && ids.redditPostId) lanes.push('reddit'); | ||
| if (platforms.includes('pinterest') && ids.pinId) lanes.push('pinterest'); | ||
| if (platforms.includes('tiktok') && ids.tiktokVideoId) lanes.push('tiktok'); | ||
| return lanes; | ||
@@ -46,0 +56,0 @@ } |
+1
-1
| { | ||
| "name": "pendpost", | ||
| "version": "1.1.1", | ||
| "version": "1.2.0", | ||
| "description": "pendpost is a free, open-source (MIT), local-first social media planner where an AI agent drafts and schedules posts across Instagram, Facebook, LinkedIn, YouTube, and X behind a human approval gate you control.", | ||
@@ -5,0 +5,0 @@ "type": "module", |
+4
-4
@@ -15,3 +15,3 @@ # pendpost | ||
| pendpost is a free, open-source (MIT), local-first social media planner where an AI agent drafts and schedules posts across Instagram, Facebook, LinkedIn, YouTube, and X behind a human approval gate you control. It is MCP-native: AI agents draft, lint, schedule, and queue your posts, but nothing goes live until a human approves it. It is built for developers, agencies, and technical solopreneurs who want agents to do the work without handing them the keys, and without getting accounts flagged. | ||
| pendpost is a free, open-source (MIT), local-first social media planner where an AI agent drafts and schedules posts across Instagram, Facebook, LinkedIn, YouTube, X, Telegram, and Discord behind a human approval gate you control. It is MCP-native: AI agents draft, lint, schedule, and queue your posts, but nothing goes live until a human approves it. It is built for developers, agencies, and technical solopreneurs who want agents to do the work without handing them the keys, and without getting accounts flagged. | ||
@@ -23,6 +23,6 @@ ## Why pendpost is different (not just a scheduler) | ||
| <p align="center"> | ||
| <img src="brand/github/approval-gate-diagram-preview.png" alt="The approval gate: an agent drafts, but cannot approve its own draft; a human approves; only then does it publish" width="820"> | ||
| <img src="brand/github/approval-gate-diagram-preview.png" alt="The approval gate: an agent drafts, you approve by default, and only then does it publish" width="820"> | ||
| </p> | ||
| - **Human approval gate.** Every post carries an approval state (`draft`, `approved`, `rejected`) and is fail-closed: a post with no approval will not publish. `plan_create_post` always creates a draft, and only `approve_post` or `reject_post` can flip it. There is no self-approval, so the actor who created a post can never approve it (an agent cannot bless its own draft); the owner is exempt. | ||
| - **Human approval gate.** Every post carries an approval state (`draft`, `approved`, `rejected`) and is fail-closed: a post with no approval will not publish. `plan_create_post` always creates a draft, and only `approve_post` or `reject_post` can flip it. Nothing publishes until it's approved. By default that's you; auto-approve is owner-only and revocable. | ||
| - **Anti-ban circuit breakers.** A Meta error 368 (an action block) trips a breaker that halts the Meta lane and never auto-resumes, because 368 carries no machine-readable clear time. Health probes send zero Graph traffic while blocked. A cadence cap defers bursts rather than dropping them, and a lane pause kill switch is always available. | ||
@@ -101,3 +101,3 @@ - **Humanizer brand-lint.** Captions are checked before publish against editable rules in `rules.json`. The humanizer layer flags English AI-writing tells. Errors block publish; warnings are advisory. | ||
| - **Compose:** `plan_create_post`, `plan_update_post`, `plan_delete_post`, `campaign_create`, `campaign_set_active`. | ||
| - **Approve (the human gate):** `approve_post`, `reject_post`. An agent can never approve its own draft. | ||
| - **Approve (the human gate):** `approve_post`, `reject_post`. Nothing publishes until it's approved; by default that's you. | ||
| - **Schedule and publish:** `scheduler_set`, `publish_due_run`, `reschedule`, `unschedule`, `mark_posted`, `verify_post`. | ||
@@ -104,0 +104,0 @@ - **Covers and assets:** `set_cover`, `clear_cover`, `asset_upload`, `rename_asset`, `delete_asset`. |
@@ -25,2 +25,3 @@ #!/usr/bin/env node | ||
| * schedule --plan <post-plan.json> [--only <id>] [--dry-run] natively schedule (private + publishAt) | ||
| * release --plan <post-plan.json> [--only <id>] make a private-overdue scheduled video public now (no re-upload) | ||
| * status --plan <post-plan.json> per-entry live state | ||
@@ -1001,2 +1002,64 @@ * delete --id <videoId> delete a video (cleanup / unschedule) | ||
| // Recover a natively-scheduled video YouTube left PRIVATE past its publishAt | ||
| // (verify read-back state 'private-overdue'): flip it public via a metadata-only | ||
| // videos.update - NEVER a re-upload (the bytes are already on YouTube; re-running | ||
| // `schedule` would mint a duplicate). The pendpost scheduler owes this 'release' | ||
| // only once the id exists AND the read-back says private-overdue (lib/scheduler.mjs | ||
| // lanesFor), so in normal operation the GET-status guard below just confirms the | ||
| // state; the guard also keeps a bare CLI `release --plan X` idempotent and safe - | ||
| // an already-public video is a no-op, a still-future schedule is left untouched. | ||
| async function cmdRelease(args) { | ||
| const { abs, plan } = loadPlan(args.plan); | ||
| const token = await getAccessToken(); | ||
| const targets = (plan.posts || []).filter((p) => (!args.only || p.id === args.only) && isYouTube(p) && p.ytVideoId); | ||
| if (!targets.length) { console.log('[done] release complete - no posts with a ytVideoId.'); return; } | ||
| let items = []; | ||
| try { | ||
| const data = await api('GET', '/videos', { query: { part: 'status', id: targets.map((p) => p.ytVideoId).join(',') }, token }); | ||
| items = data.items || []; | ||
| } catch (err) { | ||
| for (const post of targets) RUN.results.push({ postId: post.id, platform: 'youtube', action: 'release', ok: false, errorCode: 'engine_failure', errorMessage: err.message.slice(0, 300) }); | ||
| console.error(`[err] videos.list failed - ${err.message}`); | ||
| return; | ||
| } | ||
| const now = Date.now(); | ||
| for (const post of targets) { | ||
| const v = items.find((i) => i.id === post.ytVideoId); | ||
| const permalink = `https://youtu.be/${post.ytVideoId}`; | ||
| if (!v) { | ||
| RUN.results.push({ postId: post.id, platform: 'youtube', action: 'release', ok: false, errorCode: 'engine_failure', errorMessage: `video ${post.ytVideoId} not found (deleted?)` }); | ||
| console.log(`[warn] ${post.id}: video ${post.ytVideoId} missing - cannot release.`); | ||
| continue; | ||
| } | ||
| const privacy = v.status?.privacyStatus; | ||
| const publishAt = v.status?.publishAt ? Date.parse(v.status.publishAt) : NaN; | ||
| if (privacy === 'public') { | ||
| RUN.results.push({ postId: post.id, platform: 'youtube', action: 'release', ok: true, id: post.ytVideoId, live: true, state: 'public', permalink }); | ||
| console.log(`[ok] ${post.id}: already public - no action.`); | ||
| continue; | ||
| } | ||
| if (!Number.isNaN(publishAt) && publishAt > now) { | ||
| RUN.results.push({ postId: post.id, platform: 'youtube', action: 'release', ok: false, errorCode: 'invalid_input', errorMessage: `still natively scheduled for ${v.status.publishAt} - not releasing early` }); | ||
| console.log(`[skip] ${post.id}: still scheduled (publishAt ${v.status.publishAt}) - not releasing early.`); | ||
| continue; | ||
| } | ||
| // private-overdue: YouTube did NOT auto-publish at publishAt. Make it public now. | ||
| // part=status REPLACES the status part, so selfDeclaredMadeForKids must be re-sent | ||
| // (mirrors buildMeta) and publishAt is omitted to clear the (passed) schedule. | ||
| try { | ||
| await api('PUT', '/videos', { query: { part: 'status' }, body: { id: post.ytVideoId, status: { privacyStatus: 'public', selfDeclaredMadeForKids: false } }, token }); | ||
| appendAttempt(post, { ts: new Date().toISOString(), platform: 'youtube', action: 'release', ok: true, errorCode: null, errorMessage: null, lateMin: 0, actor: ACTOR }); | ||
| await savePlan(abs, plan, [post.id]); | ||
| RUN.results.push({ postId: post.id, platform: 'youtube', action: 'release', ok: true, id: post.ytVideoId, live: true, state: 'public', permalink }); | ||
| console.log(`[ok] ${post.id}: released - video ${post.ytVideoId} is now public.`); | ||
| } catch (err) { | ||
| appendAttempt(post, { ts: new Date().toISOString(), platform: 'youtube', action: 'release', ok: false, errorCode: 'engine_failure', errorMessage: err.message.slice(0, 300), lateMin: 0, actor: ACTOR }); | ||
| await savePlan(abs, plan, [post.id]); | ||
| RUN.results.push({ postId: post.id, platform: 'youtube', action: 'release', ok: false, errorCode: 'engine_failure', errorMessage: err.message.slice(0, 300) }); | ||
| console.error(`[err] ${post.id}: release failed - ${err.message}`); | ||
| } | ||
| } | ||
| console.log('[done] release complete.'); | ||
| } | ||
| function parseArgs(argv) { | ||
@@ -1023,2 +1086,3 @@ const args = { _: [] }; | ||
| schedule: cmdSchedule, | ||
| release: cmdRelease, | ||
| status: cmdStatus, | ||
@@ -1060,3 +1124,3 @@ verify: cmdVerify, | ||
| } | ||
| if (['schedule', 'status', 'set-thumbnail', 'caption', 'comment', 'insights', 'verify'].includes(args._[0]) && !args.plan) { | ||
| if (['schedule', 'release', 'status', 'set-thumbnail', 'caption', 'comment', 'insights', 'verify'].includes(args._[0]) && !args.plan) { | ||
| console.error(`[err] ${args._[0]} requires --plan <post-plan.json>`); | ||
@@ -1063,0 +1127,0 @@ process.exit(2); |
| *,:before,:after,::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:#3b82f680;--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }*,:before,:after{box-sizing:border-box;border:0 solid #e5e7eb}:before,:after{--tw-content:""}html,:host{-webkit-text-size-adjust:100%;tab-size:4;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;line-height:1.5}body{line-height:inherit;margin:0}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-feature-settings:normal;font-variation-settings:normal;font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-feature-settings:inherit;font-variation-settings:inherit;font-family:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:#0000;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dl,dd,h1,h2,h3,h4,h5,h6,hr,figure,p,pre{margin:0}fieldset{margin:0;padding:0}legend{padding:0}ol,ul,menu{margin:0;padding:0;list-style:none}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder{opacity:1;color:#9ca3af}textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}button,[role=button]{cursor:pointer}:disabled{cursor:default}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}body{--tw-bg-opacity:1;background-color:rgb(248 250 252/var(--tw-bg-opacity,1));--tw-text-opacity:1;color:rgb(30 41 59/var(--tw-text-opacity,1));-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;font-family:Inter,system-ui,sans-serif}body:is(.dark *){--tw-bg-opacity:1;background-color:rgb(9 9 11/var(--tw-bg-opacity,1));--tw-text-opacity:1;color:rgb(244 244 245/var(--tw-text-opacity,1))}html.theme-ready body{transition:background-color .35s,color .35s}@media (prefers-reduced-motion:reduce){html.theme-ready body{transition:none}}.container{width:100%}@media (width>=640px){.container{max-width:640px}}@media (width>=768px){.container{max-width:768px}}@media (width>=1024px){.container{max-width:1024px}}@media (width>=1280px){.container{max-width:1280px}}@media (width>=1536px){.container{max-width:1536px}}.glass-panel{--tw-shadow:0 8px 32px #0000000d;--tw-shadow-colored:0 8px 32px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000), var(--tw-ring-shadow,0 0 #0000), var(--tw-shadow);--tw-backdrop-blur:blur(40px);-webkit-backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);background-color:#ffffffb3;border-width:1px;border-color:#ffffff80}.glass-panel:is(.dark *){background-color:#18181b99;border-color:#ffffff1a}.scrollbar-soft{scrollbar-width:thin;scrollbar-color:#a1a1aa80 transparent}.scrollbar-soft::-webkit-scrollbar{width:8px;height:8px}.scrollbar-soft::-webkit-scrollbar-track{background:0 0}.scrollbar-soft::-webkit-scrollbar-thumb{background-color:#a1a1aa73;background-clip:padding-box;border:2px solid #0000;border-radius:9999px}.scrollbar-soft::-webkit-scrollbar-thumb:hover{background-color:#71717ab3;background-clip:padding-box}.pp-seek{appearance:none;cursor:pointer;background:#ffffff38;border-radius:9999px;height:3px}.pp-seek::-webkit-slider-thumb{appearance:none;background:#fff;border-radius:9999px;width:9px;height:9px;transition:transform .12s;box-shadow:0 1px 3px #0006}.pp-seek:hover::-webkit-slider-thumb{transform:scale(1.4)}.pp-seek:focus-visible::-webkit-slider-thumb{transform:scale(1.4)}.pp-seek::-moz-range-thumb{background:#fff;border:none;border-radius:9999px;width:9px;height:9px;box-shadow:0 1px 3px #0006}.pp-seek:focus-visible{outline:none}@media (prefers-reduced-motion:reduce){.pp-seek::-webkit-slider-thumb{transition:none}}.sr-only{clip:rect(0, 0, 0, 0);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.pointer-events-none{pointer-events:none}.visible{visibility:visible}.invisible{visibility:hidden}.collapse{visibility:collapse}.static{position:static}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.sticky{position:sticky}.-inset-5{inset:-1.25rem}.inset-0{inset:0}.inset-x-0{left:0;right:0}.inset-y-0{top:0;bottom:0}.-bottom-24{bottom:-6rem}.-left-32{left:-8rem}.-right-1{right:-.25rem}.-right-24{right:-6rem}.-top-1{top:-.25rem}.-top-32{top:-8rem}.bottom-0{bottom:0}.bottom-1\.5{bottom:.375rem}.bottom-4{bottom:1rem}.left-0{left:0}.left-1\/3{left:33.3333%}.left-2\.5{left:.625rem}.right-0{right:0}.right-0\.5{right:.125rem}.right-1{right:.25rem}.right-1\.5{right:.375rem}.right-2{right:.5rem}.right-2\.5{right:.625rem}.right-3{right:.75rem}.right-4{right:1rem}.top-0{top:0}.top-0\.5{top:.125rem}.top-1{top:.25rem}.top-1\.5{top:.375rem}.top-1\/2{top:50%}.top-1\/3{top:33.3333%}.top-2{top:.5rem}.z-0{z-index:0}.z-10{z-index:10}.z-40{z-index:40}.z-50{z-index:50}.z-\[60\]{z-index:60}.z-\[70\]{z-index:70}.z-\[80\]{z-index:80}.col-span-3{grid-column:span 3/span 3}.col-span-7{grid-column:span 7/span 7}.m-0{margin:0}.-mx-2{margin-left:-.5rem;margin-right:-.5rem}.mx-0\.5{margin-left:.125rem;margin-right:.125rem}.mx-1{margin-left:.25rem;margin-right:.25rem}.mx-auto{margin-left:auto;margin-right:auto}.my-1{margin-top:.25rem;margin-bottom:.25rem}.-mt-0\.5{margin-top:-.125rem}.-mt-1{margin-top:-.25rem}.-mt-2{margin-top:-.5rem}.mb-1{margin-bottom:.25rem}.mb-1\.5{margin-bottom:.375rem}.mb-2{margin-bottom:.5rem}.mb-3{margin-bottom:.75rem}.mb-4{margin-bottom:1rem}.ml-1{margin-left:.25rem}.ml-1\.5{margin-left:.375rem}.ml-4{margin-left:1rem}.ml-auto{margin-left:auto}.mr-1{margin-right:.25rem}.mr-1\.5{margin-right:.375rem}.mr-auto{margin-right:auto}.mt-0\.5{margin-top:.125rem}.mt-1{margin-top:.25rem}.mt-1\.5{margin-top:.375rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.mt-auto{margin-top:auto}.mt-px{margin-top:1px}.line-clamp-2{-webkit-line-clamp:2;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}.line-clamp-3{-webkit-line-clamp:3;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}.block{display:block}.inline{display:inline}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.contents{display:contents}.hidden{display:none}.aspect-\[1\.91\/1\]{aspect-ratio:1.91}.aspect-\[4\/5\]{aspect-ratio:4/5}.aspect-\[9\/16\]{aspect-ratio:9/16}.aspect-square{aspect-ratio:1}.aspect-video{aspect-ratio:16/9}.h-1{height:.25rem}.h-1\.5{height:.375rem}.h-10{height:2.5rem}.h-12{height:3rem}.h-14{height:3.5rem}.h-16{height:4rem}.h-2{height:.5rem}.h-24{height:6rem}.h-28{height:7rem}.h-3{height:.75rem}.h-4{height:1rem}.h-5{height:1.25rem}.h-6{height:1.5rem}.h-7{height:1.75rem}.h-72{height:18rem}.h-8{height:2rem}.h-80{height:20rem}.h-9{height:2.25rem}.h-96{height:24rem}.h-\[252px\]{height:252px}.h-\[72px\]{height:72px}.h-full{height:100%}.h-px{height:1px}.max-h-72{max-height:18rem}.max-h-\[70vh\]{max-height:70vh}.max-h-\[85vh\]{max-height:85vh}.max-h-\[92vh\]{max-height:92vh}.min-h-0{min-height:0}.min-h-48{min-height:12rem}.min-h-\[92px\]{min-height:92px}.min-h-dvh{min-height:100dvh}.w-1{width:.25rem}.w-10{width:2.5rem}.w-11{width:2.75rem}.w-12{width:3rem}.w-16{width:4rem}.w-2{width:.5rem}.w-24{width:6rem}.w-28{width:7rem}.w-4{width:1rem}.w-44{width:11rem}.w-56{width:14rem}.w-6{width:1.5rem}.w-60{width:15rem}.w-64{width:16rem}.w-7{width:1.75rem}.w-72{width:18rem}.w-8{width:2rem}.w-80{width:20rem}.w-9{width:2.25rem}.w-96{width:24rem}.w-\[420px\]{width:420px}.w-\[440px\]{width:440px}.w-\[72px\]{width:72px}.w-auto{width:auto}.w-fit{width:fit-content}.w-full{width:100%}.w-px{width:1px}.min-w-0{min-width:0}.min-w-\[1\.25rem\]{min-width:1.25rem}.min-w-\[840px\]{min-width:840px}.max-w-2xl{max-width:42rem}.max-w-3xl{max-width:48rem}.max-w-4xl{max-width:56rem}.max-w-5xl{max-width:64rem}.max-w-\[10rem\]{max-width:10rem}.max-w-\[14ch\]{max-width:14ch}.max-w-\[16rem\]{max-width:16rem}.max-w-\[4rem\]{max-width:4rem}.max-w-\[80\%\]{max-width:80%}.max-w-\[90vw\]{max-width:90vw}.max-w-\[94vw\]{max-width:94vw}.max-w-full{max-width:100%}.max-w-lg{max-width:32rem}.max-w-none{max-width:none}.max-w-sm{max-width:24rem}.max-w-xl{max-width:36rem}.max-w-xs{max-width:20rem}.flex-1{flex:1}.shrink-0{flex-shrink:0}.basis-full{flex-basis:100%}.-translate-x-1\/2{--tw-translate-x:-50%;transform:translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.-translate-y-1\/2{--tw-translate-y:-50%;transform:translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.translate-x-0\.5{--tw-translate-x:.125rem;transform:translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.translate-x-4{--tw-translate-x:1rem;transform:translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.rotate-180{--tw-rotate:180deg;transform:translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.transform{transform:translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@keyframes blob{0%,to{transform:translate(0)scale(1)}33%{transform:translate(50px,-70px)scale(1.08)}66%{transform:translate(-40px,40px)scale(.94)}}.animate-blob{animation:20s ease-in-out infinite blob}.animate-blob-slow{animation:28s ease-in-out infinite reverse blob}@keyframes ping{75%,to{opacity:0;transform:scale(2)}}.animate-ping{animation:1s cubic-bezier(0,0,.2,1) infinite ping}@keyframes pulse{50%{opacity:.5}}.animate-pulse{animation:2s cubic-bezier(.4,0,.6,1) infinite pulse}@keyframes slide-in{0%{opacity:0;transform:translate(48px)}to{opacity:1;transform:translate(0)}}.animate-slide-in{animation:.26s cubic-bezier(.32,.72,0,1) both slide-in}@keyframes spin{to{transform:rotate(360deg)}}.animate-spin{animation:1s linear infinite spin}.cursor-default{cursor:default}.cursor-none{cursor:none}.cursor-not-allowed{cursor:not-allowed}.cursor-pointer{cursor:pointer}.resize-y{resize:vertical}.list-decimal{list-style-type:decimal}.list-none{list-style-type:none}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.grid-cols-7{grid-template-columns:repeat(7,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.place-items-center{place-items:center}.content-start{align-content:flex-start}.items-start{align-items:flex-start}.items-end{align-items:flex-end}.items-center{align-items:center}.items-baseline{align-items:baseline}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-0\.5{gap:.125rem}.gap-1{gap:.25rem}.gap-1\.5{gap:.375rem}.gap-2{gap:.5rem}.gap-2\.5{gap:.625rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-5{gap:1.25rem}.gap-6{gap:1.5rem}.gap-x-1\.5{-moz-column-gap:.375rem;column-gap:.375rem}.gap-x-2{-moz-column-gap:.5rem;column-gap:.5rem}.gap-x-2\.5{-moz-column-gap:.625rem;column-gap:.625rem}.gap-x-3{-moz-column-gap:.75rem;column-gap:.75rem}.gap-x-4{-moz-column-gap:1rem;column-gap:1rem}.gap-y-0\.5{row-gap:.125rem}.gap-y-1{row-gap:.25rem}.gap-y-1\.5{row-gap:.375rem}.gap-y-3{row-gap:.75rem}.space-y-0\.5>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.125rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.125rem * var(--tw-space-y-reverse))}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.25rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.25rem * var(--tw-space-y-reverse))}.space-y-1\.5>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.375rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.375rem * var(--tw-space-y-reverse))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.5rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.5rem * var(--tw-space-y-reverse))}.space-y-2\.5>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.625rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.625rem * var(--tw-space-y-reverse))}.space-y-3>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.75rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.75rem * var(--tw-space-y-reverse))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1rem * var(--tw-space-y-reverse))}.space-y-5>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1.25rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.25rem * var(--tw-space-y-reverse))}.space-y-6>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1.5rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.5rem * var(--tw-space-y-reverse))}.divide-y>:not([hidden])~:not([hidden]){--tw-divide-y-reverse:0;border-top-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)));border-bottom-width:calc(1px * var(--tw-divide-y-reverse))}.divide-black\/5>:not([hidden])~:not([hidden]){border-color:#0000000d}.self-center{align-self:center}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.whitespace-nowrap{white-space:nowrap}.whitespace-pre-wrap{white-space:pre-wrap}.break-words{overflow-wrap:break-word}.break-all{word-break:break-all}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:1rem}.rounded-\[10px\]{border-radius:10px}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:.5rem}.rounded-md{border-radius:.375rem}.rounded-xl{border-radius:.75rem}.rounded-l-2xl{border-top-left-radius:1rem;border-bottom-left-radius:1rem}.border{border-width:1px}.border-0{border-width:0}.border-2{border-width:2px}.border-b{border-bottom-width:1px}.border-l{border-left-width:1px}.border-t{border-top-width:1px}.border-dashed{border-style:dashed}.border-amber-500\/30{border-color:#f59e0b4d}.border-black\/10{border-color:#0000001a}.border-black\/5{border-color:#0000000d}.border-brand{border-color:var(--accent,#0f766e)}.border-zinc-200\/50{border-color:#e4e4e780}.border-zinc-200\/60{border-color:#e4e4e799}.border-zinc-200\/70{border-color:#e4e4e7b3}.border-zinc-300{--tw-border-opacity:1;border-color:rgb(212 212 216/var(--tw-border-opacity,1))}.border-zinc-300\/60{border-color:#d4d4d899}.border-zinc-300\/70{border-color:#d4d4d8b3}.border-zinc-900\/5{border-color:#18181b0d}.bg-amber-500{--tw-bg-opacity:1;background-color:rgb(245 158 11/var(--tw-bg-opacity,1))}.bg-amber-500\/10{background-color:#f59e0b1a}.bg-amber-500\/15{background-color:#f59e0b26}.bg-amber-500\/20{background-color:#f59e0b33}.bg-amber-500\/90{background-color:#f59e0be6}.bg-black{--tw-bg-opacity:1;background-color:rgb(0 0 0/var(--tw-bg-opacity,1))}.bg-black\/40{background-color:#0006}.bg-black\/45{background-color:#00000073}.bg-black\/5{background-color:#0000000d}.bg-black\/55{background-color:#0000008c}.bg-black\/80{background-color:#000c}.bg-black\/90{background-color:#000000e6}.bg-blue-400\/15{background-color:#60a5fa26}.bg-brand{background-color:var(--accent,#0f766e)}.bg-current{background-color:currentColor}.bg-cyan-400\/20{background-color:#22d3ee33}.bg-cyan-500{--tw-bg-opacity:1;background-color:rgb(6 182 212/var(--tw-bg-opacity,1))}.bg-cyan-500\/15{background-color:#06b6d426}.bg-emerald-500{--tw-bg-opacity:1;background-color:rgb(16 185 129/var(--tw-bg-opacity,1))}.bg-emerald-500\/10{background-color:#10b9811a}.bg-emerald-500\/15{background-color:#10b98126}.bg-emerald-600{--tw-bg-opacity:1;background-color:rgb(5 150 105/var(--tw-bg-opacity,1))}.bg-emerald-600\/15{background-color:#05966926}.bg-orange-500{--tw-bg-opacity:1;background-color:rgb(249 115 22/var(--tw-bg-opacity,1))}.bg-orange-500\/15{background-color:#f9731626}.bg-red-500{--tw-bg-opacity:1;background-color:rgb(239 68 68/var(--tw-bg-opacity,1))}.bg-red-500\/10{background-color:#ef44441a}.bg-red-500\/15{background-color:#ef444426}.bg-red-500\/70{background-color:#ef4444b3}.bg-red-600{--tw-bg-opacity:1;background-color:rgb(220 38 38/var(--tw-bg-opacity,1))}.bg-red-600\/90{background-color:#dc2626e6}.bg-sky-500{--tw-bg-opacity:1;background-color:rgb(14 165 233/var(--tw-bg-opacity,1))}.bg-sky-500\/15{background-color:#0ea5e926}.bg-slate-400{--tw-bg-opacity:1;background-color:rgb(148 163 184/var(--tw-bg-opacity,1))}.bg-slate-500\/10{background-color:#64748b1a}.bg-slate-500\/15{background-color:#64748b26}.bg-teal-400\/15{background-color:#2dd4bf26}.bg-teal-500{--tw-bg-opacity:1;background-color:rgb(20 184 166/var(--tw-bg-opacity,1))}.bg-teal-500\/15{background-color:#14b8a626}.bg-transparent{background-color:#0000}.bg-white{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity,1))}.bg-white\/20{background-color:#fff3}.bg-white\/25{background-color:#ffffff40}.bg-white\/40{background-color:#fff6}.bg-white\/45{background-color:#ffffff73}.bg-white\/50{background-color:#ffffff80}.bg-white\/60{background-color:#fff9}.bg-white\/70{background-color:#ffffffb3}.bg-white\/80{background-color:#fffc}.bg-white\/95{background-color:#fffffff2}.bg-zinc-100\/70{background-color:#f4f4f5b3}.bg-zinc-200{--tw-bg-opacity:1;background-color:rgb(228 228 231/var(--tw-bg-opacity,1))}.bg-zinc-200\/40{background-color:#e4e4e766}.bg-zinc-200\/50{background-color:#e4e4e780}.bg-zinc-200\/60{background-color:#e4e4e799}.bg-zinc-200\/70{background-color:#e4e4e7b3}.bg-zinc-200\/80{background-color:#e4e4e7cc}.bg-zinc-300{--tw-bg-opacity:1;background-color:rgb(212 212 216/var(--tw-bg-opacity,1))}.bg-zinc-300\/40{background-color:#d4d4d866}.bg-zinc-300\/70{background-color:#d4d4d8b3}.bg-zinc-400{--tw-bg-opacity:1;background-color:rgb(161 161 170/var(--tw-bg-opacity,1))}.bg-zinc-500\/10{background-color:#71717a1a}.bg-zinc-500\/15{background-color:#71717a26}.bg-zinc-900{--tw-bg-opacity:1;background-color:rgb(24 24 27/var(--tw-bg-opacity,1))}.bg-zinc-900\/5{background-color:#18181b0d}.bg-zinc-900\/60{background-color:#18181b99}.bg-zinc-900\/\[0\.06\]{background-color:#18181b0f}.bg-gradient-to-t{background-image:linear-gradient(to top, var(--tw-gradient-stops))}.from-black\/55{--tw-gradient-from:#0000008c var(--tw-gradient-from-position);--tw-gradient-to:#0000 var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from), var(--tw-gradient-to)}.via-black\/25{--tw-gradient-to:#0000 var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from), #00000040 var(--tw-gradient-via-position), var(--tw-gradient-to)}.to-transparent{--tw-gradient-to:transparent var(--tw-gradient-to-position)}.fill-zinc-900{fill:#18181b}.object-contain{-o-object-fit:contain;object-fit:contain}.object-cover{-o-object-fit:cover;object-fit:cover}.object-top{-o-object-position:top;object-position:top}.p-0\.5{padding:.125rem}.p-1{padding:.25rem}.p-1\.5{padding:.375rem}.p-2{padding:.5rem}.p-2\.5{padding:.625rem}.p-3{padding:.75rem}.p-4{padding:1rem}.p-5{padding:1.25rem}.p-8{padding:2rem}.px-1{padding-left:.25rem;padding-right:.25rem}.px-1\.5{padding-left:.375rem;padding-right:.375rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-2\.5{padding-left:.625rem;padding-right:.625rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-3\.5{padding-left:.875rem;padding-right:.875rem}.px-4{padding-left:1rem;padding-right:1rem}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-1\.5{padding-top:.375rem;padding-bottom:.375rem}.py-12{padding-top:3rem;padding-bottom:3rem}.py-16{padding-top:4rem;padding-bottom:4rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-2\.5{padding-top:.625rem;padding-bottom:.625rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.py-6{padding-top:1.5rem;padding-bottom:1.5rem}.py-8{padding-top:2rem;padding-bottom:2rem}.pb-1{padding-bottom:.25rem}.pb-1\.5{padding-bottom:.375rem}.pb-2{padding-bottom:.5rem}.pl-0\.5{padding-left:.125rem}.pl-2{padding-left:.5rem}.pl-3{padding-left:.75rem}.pl-8{padding-left:2rem}.pl-\[25px\]{padding-left:25px}.pr-0\.5{padding-right:.125rem}.pr-1{padding-right:.25rem}.pr-2{padding-right:.5rem}.pr-3{padding-right:.75rem}.pr-6{padding-right:1.5rem}.pr-9{padding-right:2.25rem}.pt-0\.5{padding-top:.125rem}.pt-1{padding-top:.25rem}.pt-1\.5{padding-top:.375rem}.pt-2{padding-top:.5rem}.pt-2\.5{padding-top:.625rem}.pt-3{padding-top:.75rem}.pt-4{padding-top:1rem}.pt-6{padding-top:1.5rem}.pt-\[12vh\]{padding-top:12vh}.text-left{text-align:left}.text-center{text-align:center}.text-right{text-align:right}.align-middle{vertical-align:middle}.font-body,.font-display{font-family:Inter,system-ui,sans-serif}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.text-2xl{font-size:1.5rem;line-height:2rem}.text-\[10px\]{font-size:10px}.text-\[11px\]{font-size:11px}.text-\[13px\]{font-size:13px}.text-\[9px\]{font-size:9px}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xs{font-size:.75rem;line-height:1rem}.font-bold{font-weight:700}.font-medium{font-weight:500}.font-normal{font-weight:400}.uppercase{text-transform:uppercase}.lowercase{text-transform:lowercase}.tabular-nums{--tw-numeric-spacing:tabular-nums;font-variant-numeric:var(--tw-ordinal) var(--tw-slashed-zero) var(--tw-numeric-figure) var(--tw-numeric-spacing) var(--tw-numeric-fraction)}.leading-none{line-height:1}.leading-relaxed{line-height:1.625}.leading-snug{line-height:1.375}.leading-tight{line-height:1.25}.tracking-tight{letter-spacing:-.025em}.tracking-wide{letter-spacing:.025em}.text-\[\#0A66C2\]{--tw-text-opacity:1;color:rgb(10 102 194/var(--tw-text-opacity,1))}.text-\[\#1877F2\]{--tw-text-opacity:1;color:rgb(24 119 242/var(--tw-text-opacity,1))}.text-\[\#E4405F\]{--tw-text-opacity:1;color:rgb(228 64 95/var(--tw-text-opacity,1))}.text-\[\#FF0000\]{--tw-text-opacity:1;color:rgb(255 0 0/var(--tw-text-opacity,1))}.text-amber-500{--tw-text-opacity:1;color:rgb(245 158 11/var(--tw-text-opacity,1))}.text-amber-600{--tw-text-opacity:1;color:rgb(217 119 6/var(--tw-text-opacity,1))}.text-amber-600\/90{color:#d97706e6}.text-amber-700{--tw-text-opacity:1;color:rgb(180 83 9/var(--tw-text-opacity,1))}.text-amber-700\/70{color:#b45309b3}.text-amber-700\/80{color:#b45309cc}.text-amber-800{--tw-text-opacity:1;color:rgb(146 64 14/var(--tw-text-opacity,1))}.text-brand{color:var(--accent,#0f766e)}.text-cyan-700{--tw-text-opacity:1;color:rgb(14 116 144/var(--tw-text-opacity,1))}.text-emerald-500{--tw-text-opacity:1;color:rgb(16 185 129/var(--tw-text-opacity,1))}.text-emerald-600{--tw-text-opacity:1;color:rgb(5 150 105/var(--tw-text-opacity,1))}.text-emerald-700{--tw-text-opacity:1;color:rgb(4 120 87/var(--tw-text-opacity,1))}.text-emerald-700\/70{color:#047857b3}.text-emerald-700\/80{color:#047857cc}.text-emerald-800{--tw-text-opacity:1;color:rgb(6 95 70/var(--tw-text-opacity,1))}.text-orange-700{--tw-text-opacity:1;color:rgb(194 65 12/var(--tw-text-opacity,1))}.text-red-500{--tw-text-opacity:1;color:rgb(239 68 68/var(--tw-text-opacity,1))}.text-red-600{--tw-text-opacity:1;color:rgb(220 38 38/var(--tw-text-opacity,1))}.text-red-600\/90{color:#dc2626e6}.text-red-700{--tw-text-opacity:1;color:rgb(185 28 28/var(--tw-text-opacity,1))}.text-red-700\/80{color:#b91c1ccc}.text-sky-700{--tw-text-opacity:1;color:rgb(3 105 161/var(--tw-text-opacity,1))}.text-slate-600{--tw-text-opacity:1;color:rgb(71 85 105/var(--tw-text-opacity,1))}.text-teal-700{--tw-text-opacity:1;color:rgb(15 118 110/var(--tw-text-opacity,1))}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.text-white\/75{color:#ffffffbf}.text-white\/85{color:#ffffffd9}.text-white\/90{color:#ffffffe6}.text-zinc-300{--tw-text-opacity:1;color:rgb(212 212 216/var(--tw-text-opacity,1))}.text-zinc-400{--tw-text-opacity:1;color:rgb(161 161 170/var(--tw-text-opacity,1))}.text-zinc-50{--tw-text-opacity:1;color:rgb(250 250 250/var(--tw-text-opacity,1))}.text-zinc-500{--tw-text-opacity:1;color:rgb(113 113 122/var(--tw-text-opacity,1))}.text-zinc-600{--tw-text-opacity:1;color:rgb(82 82 91/var(--tw-text-opacity,1))}.text-zinc-700{--tw-text-opacity:1;color:rgb(63 63 70/var(--tw-text-opacity,1))}.text-zinc-800{--tw-text-opacity:1;color:rgb(39 39 42/var(--tw-text-opacity,1))}.text-zinc-900{--tw-text-opacity:1;color:rgb(24 24 27/var(--tw-text-opacity,1))}.text-zinc-950{--tw-text-opacity:1;color:rgb(9 9 11/var(--tw-text-opacity,1))}.underline{text-decoration-line:underline}.decoration-zinc-400{text-decoration-color:#a1a1aa}.decoration-dotted{text-decoration-style:dotted}.underline-offset-2{text-underline-offset:2px}.accent-brand{accent-color:var(--accent,#0f766e)}.accent-emerald-600{accent-color:#059669}.opacity-0{opacity:0}.opacity-100{opacity:1}.opacity-40{opacity:.4}.opacity-50{opacity:.5}.opacity-60{opacity:.6}.opacity-80{opacity:.8}.opacity-\[0\.03\]{opacity:.03}.mix-blend-overlay{mix-blend-mode:overlay}.shadow{--tw-shadow:0 1px 3px 0 #0000001a, 0 1px 2px -1px #0000001a;--tw-shadow-colored:0 1px 3px 0 var(--tw-shadow-color), 0 1px 2px -1px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000), var(--tw-ring-shadow,0 0 #0000), var(--tw-shadow)}.shadow-2xl{--tw-shadow:0 25px 50px -12px #00000040;--tw-shadow-colored:0 25px 50px -12px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000), var(--tw-ring-shadow,0 0 #0000), var(--tw-shadow)}.shadow-\[0_8px_32px_rgba\(0\,0\,0\,0\.05\)\]{--tw-shadow:0 8px 32px #0000000d;--tw-shadow-colored:0 8px 32px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000), var(--tw-ring-shadow,0 0 #0000), var(--tw-shadow)}.shadow-lg{--tw-shadow:0 10px 15px -3px #0000001a, 0 4px 6px -4px #0000001a;--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color), 0 4px 6px -4px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000), var(--tw-ring-shadow,0 0 #0000), var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 2px 0 #0000000d;--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000), var(--tw-ring-shadow,0 0 #0000), var(--tw-shadow)}.shadow-xl{--tw-shadow:0 20px 25px -5px #0000001a, 0 8px 10px -6px #0000001a;--tw-shadow-colored:0 20px 25px -5px var(--tw-shadow-color), 0 8px 10px -6px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000), var(--tw-ring-shadow,0 0 #0000), var(--tw-shadow)}.outline-none{outline-offset:2px;outline:2px solid #0000}.outline{outline-style:solid}.ring{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow,0 0 #0000)}.ring-1{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow,0 0 #0000)}.ring-2{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow,0 0 #0000)}.ring-amber-300\/40{--tw-ring-color:#fcd34d66}.ring-amber-500\/30{--tw-ring-color:#f59e0b4d}.ring-amber-500\/40{--tw-ring-color:#f59e0b66}.ring-black\/10{--tw-ring-color:#0000001a}.ring-brand{--tw-ring-color:var(--accent,#0f766e)}.ring-cyan-500\/30{--tw-ring-color:#06b6d44d}.ring-emerald-500\/30{--tw-ring-color:#10b9814d}.ring-emerald-600\/40{--tw-ring-color:#05966966}.ring-orange-500\/30{--tw-ring-color:#f973164d}.ring-red-300\/40{--tw-ring-color:#fca5a566}.ring-red-500\/30{--tw-ring-color:#ef44444d}.ring-red-500\/40{--tw-ring-color:#ef444466}.ring-red-500\/60{--tw-ring-color:#ef444499}.ring-sky-500\/30{--tw-ring-color:#0ea5e94d}.ring-slate-500\/30{--tw-ring-color:#64748b4d}.ring-teal-500\/30{--tw-ring-color:#14b8a64d}.ring-transparent{--tw-ring-color:transparent}.ring-white{--tw-ring-opacity:1;--tw-ring-color:rgb(255 255 255/var(--tw-ring-opacity,1))}.ring-white\/20{--tw-ring-color:#fff3}.ring-zinc-300{--tw-ring-opacity:1;--tw-ring-color:rgb(212 212 216/var(--tw-ring-opacity,1))}.ring-zinc-500\/20{--tw-ring-color:#71717a33}.ring-zinc-500\/30{--tw-ring-color:#71717a4d}.ring-zinc-900\/10{--tw-ring-color:#18181b1a}.ring-zinc-900\/5{--tw-ring-color:#18181b0d}.ring-zinc-900\/\[0\.06\]{--tw-ring-color:#18181b0f}.blur{--tw-blur:blur(8px);filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.blur-3xl{--tw-blur:blur(64px);filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.drop-shadow{--tw-drop-shadow:drop-shadow(0 1px 2px #0000001a) drop-shadow(0 1px 1px #0000000f);filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.backdrop-blur{--tw-backdrop-blur:blur(8px);-webkit-backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}.backdrop-blur-sm{--tw-backdrop-blur:blur(4px);-webkit-backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}.backdrop-blur-xl{--tw-backdrop-blur:blur(24px);-webkit-backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}.transition{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter,backdrop-filter;transition-duration:.15s;transition-timing-function:cubic-bezier(.4,0,.2,1)}.transition-opacity{transition-property:opacity;transition-duration:.15s;transition-timing-function:cubic-bezier(.4,0,.2,1)}.transition-transform{transition-property:transform;transition-duration:.15s;transition-timing-function:cubic-bezier(.4,0,.2,1)}.duration-200{transition-duration:.2s}.ease-in-out{transition-timing-function:cubic-bezier(.4,0,.2,1)}@font-face{font-family:Inter;font-style:normal;font-weight:100 900;font-display:swap;src:url(/assets/inter-latin-variable-Dx4kXJAl.woff2)format("woff2")}@keyframes pp-breathe{0%,to{transform:scale(1)}50%{transform:scale(1.055)}}@keyframes pp-chevron-clear{0%{stroke-dashoffset:64px}55%{stroke-dashoffset:0}80%{stroke-dashoffset:0}to{stroke-dashoffset:-64px}}@keyframes pp-rest{0%,to{transform:translateY(0)}50%{transform:translateY(-5px)}}@keyframes pp-glow-pulse{0%,to{opacity:.5}50%{opacity:.9}}@media (prefers-reduced-motion:reduce){[class~=pp-anim],[style*=pp-breathe],[style*=pp-chevron-clear],[style*=pp-rest],[style*=pp-glow-pulse]{animation:none!important}}.file\:mr-3::file-selector-button{margin-right:.75rem}.file\:rounded-lg::file-selector-button{border-radius:.5rem}.file\:border-0::file-selector-button{border-width:0}.file\:bg-brand::file-selector-button{background-color:var(--accent,#0f766e)}.file\:px-3::file-selector-button{padding-left:.75rem;padding-right:.75rem}.file\:py-1\.5::file-selector-button{padding-top:.375rem;padding-bottom:.375rem}.file\:text-xs::file-selector-button{font-size:.75rem;line-height:1rem}.file\:font-bold::file-selector-button{font-weight:700}.file\:text-white::file-selector-button{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.placeholder\:font-normal::placeholder{font-weight:400}.placeholder\:text-zinc-400::placeholder{--tw-text-opacity:1;color:rgb(161 161 170/var(--tw-text-opacity,1))}.last\:border-0:last-child{border-width:0}.focus-within\:outline-none:focus-within{outline-offset:2px;outline:2px solid #0000}.focus-within\:ring-2:focus-within{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow,0 0 #0000)}.focus-within\:ring-brand:focus-within{--tw-ring-color:var(--accent,#0f766e)}.hover\:-translate-y-1:hover{--tw-translate-y:-.25rem;transform:translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.hover\:border-black\/20:hover{border-color:#0003}.hover\:bg-amber-500\/20:hover{background-color:#f59e0b33}.hover\:bg-amber-500\/30:hover{background-color:#f59e0b4d}.hover\:bg-black\/70:hover{background-color:#000000b3}.hover\:bg-emerald-500\/20:hover{background-color:#10b98133}.hover\:bg-emerald-500\/25:hover{background-color:#10b98140}.hover\:bg-red-500\/10:hover{background-color:#ef44441a}.hover\:bg-red-500\/15:hover{background-color:#ef444426}.hover\:bg-red-500\/20:hover{background-color:#ef444433}.hover\:bg-red-700:hover{--tw-bg-opacity:1;background-color:rgb(185 28 28/var(--tw-bg-opacity,1))}.hover\:bg-white:hover{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity,1))}.hover\:bg-white\/15:hover{background-color:#ffffff26}.hover\:bg-white\/70:hover{background-color:#ffffffb3}.hover\:bg-white\/80:hover{background-color:#fffc}.hover\:bg-white\/90:hover{background-color:#ffffffe6}.hover\:bg-zinc-100:hover{--tw-bg-opacity:1;background-color:rgb(244 244 245/var(--tw-bg-opacity,1))}.hover\:bg-zinc-200:hover{--tw-bg-opacity:1;background-color:rgb(228 228 231/var(--tw-bg-opacity,1))}.hover\:bg-zinc-200\/40:hover{background-color:#e4e4e766}.hover\:bg-zinc-200\/50:hover{background-color:#e4e4e780}.hover\:bg-zinc-200\/60:hover{background-color:#e4e4e799}.hover\:bg-zinc-300\/50:hover{background-color:#d4d4d880}.hover\:bg-zinc-300\/60:hover{background-color:#d4d4d899}.hover\:text-amber-900:hover{--tw-text-opacity:1;color:rgb(120 53 15/var(--tw-text-opacity,1))}.hover\:text-emerald-900:hover{--tw-text-opacity:1;color:rgb(6 78 59/var(--tw-text-opacity,1))}.hover\:text-red-600:hover{--tw-text-opacity:1;color:rgb(220 38 38/var(--tw-text-opacity,1))}.hover\:text-white:hover{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.hover\:text-zinc-600:hover{--tw-text-opacity:1;color:rgb(82 82 91/var(--tw-text-opacity,1))}.hover\:text-zinc-700:hover{--tw-text-opacity:1;color:rgb(63 63 70/var(--tw-text-opacity,1))}.hover\:text-zinc-900:hover{--tw-text-opacity:1;color:rgb(24 24 27/var(--tw-text-opacity,1))}.hover\:underline:hover{text-decoration-line:underline}.hover\:decoration-zinc-700:hover{text-decoration-color:#3f3f46}.hover\:opacity-100:hover{opacity:1}.hover\:opacity-90:hover{opacity:.9}.hover\:shadow-xl:hover{--tw-shadow:0 20px 25px -5px #0000001a, 0 8px 10px -6px #0000001a;--tw-shadow-colored:0 20px 25px -5px var(--tw-shadow-color), 0 8px 10px -6px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000), var(--tw-ring-shadow,0 0 #0000), var(--tw-shadow)}.hover\:ring-1:hover{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow,0 0 #0000)}.focus\:outline-none:focus,.focus-visible\:outline-none:focus-visible{outline-offset:2px;outline:2px solid #0000}.focus-visible\:ring-2:focus-visible{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow,0 0 #0000)}.focus-visible\:ring-amber-500:focus-visible{--tw-ring-opacity:1;--tw-ring-color:rgb(245 158 11/var(--tw-ring-opacity,1))}.focus-visible\:ring-brand:focus-visible{--tw-ring-color:var(--accent,#0f766e)}.focus-visible\:ring-emerald-500:focus-visible{--tw-ring-opacity:1;--tw-ring-color:rgb(16 185 129/var(--tw-ring-opacity,1))}.focus-visible\:ring-red-500:focus-visible{--tw-ring-opacity:1;--tw-ring-color:rgb(239 68 68/var(--tw-ring-opacity,1))}.focus-visible\:ring-white\/60:focus-visible{--tw-ring-color:#fff9}.focus-visible\:ring-white\/70:focus-visible{--tw-ring-color:#ffffffb3}.focus-visible\:ring-offset-1:focus-visible{--tw-ring-offset-width:1px}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:opacity-40:disabled{opacity:.4}.disabled\:opacity-50:disabled{opacity:.5}.disabled\:opacity-60:disabled{opacity:.6}.disabled\:hover\:bg-transparent:hover:disabled{background-color:#0000}.group:hover .group-hover\:translate-x-0\.5{--tw-translate-x:.125rem;transform:translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.group:hover .group-hover\:opacity-100{opacity:1}@media (prefers-reduced-motion:reduce){.motion-reduce\:animate-none{animation:none}.motion-reduce\:transition-none{transition-property:none}.motion-reduce\:hover\:translate-y-0:hover{--tw-translate-y:0px;transform:translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}}.dark\:divide-white\/5:is(.dark *)>:not([hidden])~:not([hidden]){border-color:#ffffff0d}.dark\:border-amber-400\/30:is(.dark *){border-color:#fbbf244d}.dark\:border-brand-light:is(.dark *){border-color:var(--accent-light,#5eead4)}.dark\:border-white\/10:is(.dark *){border-color:#ffffff1a}.dark\:border-white\/5:is(.dark *){border-color:#ffffff0d}.dark\:border-zinc-600:is(.dark *){--tw-border-opacity:1;border-color:rgb(82 82 91/var(--tw-border-opacity,1))}.dark\:border-zinc-700:is(.dark *){--tw-border-opacity:1;border-color:rgb(63 63 70/var(--tw-border-opacity,1))}.dark\:border-zinc-700\/50:is(.dark *){border-color:#3f3f4680}.dark\:border-zinc-700\/60:is(.dark *){border-color:#3f3f4699}.dark\:border-zinc-700\/70:is(.dark *){border-color:#3f3f46b3}.dark\:bg-amber-400\/10:is(.dark *){background-color:#fbbf241a}.dark\:bg-black\/20:is(.dark *){background-color:#0003}.dark\:bg-blue-500\/10:is(.dark *){background-color:#3b82f61a}.dark\:bg-brand-light:is(.dark *){background-color:var(--accent-light,#5eead4)}.dark\:bg-cyan-500\/10:is(.dark *){background-color:#06b6d41a}.dark\:bg-teal-500\/10:is(.dark *){background-color:#14b8a61a}.dark\:bg-white\/10:is(.dark *){background-color:#ffffff1a}.dark\:bg-zinc-100:is(.dark *){--tw-bg-opacity:1;background-color:rgb(244 244 245/var(--tw-bg-opacity,1))}.dark\:bg-zinc-600:is(.dark *){--tw-bg-opacity:1;background-color:rgb(82 82 91/var(--tw-bg-opacity,1))}.dark\:bg-zinc-600\/70:is(.dark *){background-color:#52525bb3}.dark\:bg-zinc-700:is(.dark *){--tw-bg-opacity:1;background-color:rgb(63 63 70/var(--tw-bg-opacity,1))}.dark\:bg-zinc-700\/40:is(.dark *){background-color:#3f3f4666}.dark\:bg-zinc-700\/50:is(.dark *){background-color:#3f3f4680}.dark\:bg-zinc-700\/70:is(.dark *){background-color:#3f3f46b3}.dark\:bg-zinc-700\/80:is(.dark *){background-color:#3f3f46cc}.dark\:bg-zinc-800:is(.dark *){--tw-bg-opacity:1;background-color:rgb(39 39 42/var(--tw-bg-opacity,1))}.dark\:bg-zinc-800\/40:is(.dark *){background-color:#27272a66}.dark\:bg-zinc-800\/50:is(.dark *){background-color:#27272a80}.dark\:bg-zinc-800\/60:is(.dark *){background-color:#27272a99}.dark\:bg-zinc-800\/70:is(.dark *){background-color:#27272ab3}.dark\:bg-zinc-900:is(.dark *){--tw-bg-opacity:1;background-color:rgb(24 24 27/var(--tw-bg-opacity,1))}.dark\:bg-zinc-900\/20:is(.dark *){background-color:#18181b33}.dark\:bg-zinc-900\/30:is(.dark *){background-color:#18181b4d}.dark\:bg-zinc-900\/35:is(.dark *){background-color:#18181b59}.dark\:bg-zinc-900\/70:is(.dark *){background-color:#18181bb3}.dark\:bg-zinc-900\/95:is(.dark *){background-color:#18181bf2}.dark\:fill-zinc-100:is(.dark *){fill:#f4f4f5}.dark\:text-amber-200:is(.dark *){--tw-text-opacity:1;color:rgb(253 230 138/var(--tw-text-opacity,1))}.dark\:text-amber-300:is(.dark *){--tw-text-opacity:1;color:rgb(252 211 77/var(--tw-text-opacity,1))}.dark\:text-amber-300\/70:is(.dark *){color:#fcd34db3}.dark\:text-amber-300\/80:is(.dark *){color:#fcd34dcc}.dark\:text-amber-300\/90:is(.dark *){color:#fcd34de6}.dark\:text-amber-400:is(.dark *){--tw-text-opacity:1;color:rgb(251 191 36/var(--tw-text-opacity,1))}.dark\:text-brand-light:is(.dark *){color:var(--accent-light,#5eead4)}.dark\:text-cyan-300:is(.dark *){--tw-text-opacity:1;color:rgb(103 232 249/var(--tw-text-opacity,1))}.dark\:text-emerald-200:is(.dark *){--tw-text-opacity:1;color:rgb(167 243 208/var(--tw-text-opacity,1))}.dark\:text-emerald-300:is(.dark *){--tw-text-opacity:1;color:rgb(110 231 183/var(--tw-text-opacity,1))}.dark\:text-emerald-300\/70:is(.dark *){color:#6ee7b7b3}.dark\:text-emerald-400:is(.dark *){--tw-text-opacity:1;color:rgb(52 211 153/var(--tw-text-opacity,1))}.dark\:text-emerald-400\/70:is(.dark *){color:#34d399b3}.dark\:text-orange-300:is(.dark *){--tw-text-opacity:1;color:rgb(253 186 116/var(--tw-text-opacity,1))}.dark\:text-red-300:is(.dark *){--tw-text-opacity:1;color:rgb(252 165 165/var(--tw-text-opacity,1))}.dark\:text-red-300\/80:is(.dark *){color:#fca5a5cc}.dark\:text-red-300\/90:is(.dark *){color:#fca5a5e6}.dark\:text-red-400:is(.dark *){--tw-text-opacity:1;color:rgb(248 113 113/var(--tw-text-opacity,1))}.dark\:text-sky-300:is(.dark *){--tw-text-opacity:1;color:rgb(125 211 252/var(--tw-text-opacity,1))}.dark\:text-slate-300:is(.dark *){--tw-text-opacity:1;color:rgb(203 213 225/var(--tw-text-opacity,1))}.dark\:text-teal-300:is(.dark *){--tw-text-opacity:1;color:rgb(94 234 212/var(--tw-text-opacity,1))}.dark\:text-white:is(.dark *){--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.dark\:text-zinc-100:is(.dark *){--tw-text-opacity:1;color:rgb(244 244 245/var(--tw-text-opacity,1))}.dark\:text-zinc-200:is(.dark *){--tw-text-opacity:1;color:rgb(228 228 231/var(--tw-text-opacity,1))}.dark\:text-zinc-300:is(.dark *){--tw-text-opacity:1;color:rgb(212 212 216/var(--tw-text-opacity,1))}.dark\:text-zinc-400:is(.dark *){--tw-text-opacity:1;color:rgb(161 161 170/var(--tw-text-opacity,1))}.dark\:text-zinc-500:is(.dark *){--tw-text-opacity:1;color:rgb(113 113 122/var(--tw-text-opacity,1))}.dark\:text-zinc-600:is(.dark *){--tw-text-opacity:1;color:rgb(82 82 91/var(--tw-text-opacity,1))}.dark\:text-zinc-900:is(.dark *){--tw-text-opacity:1;color:rgb(24 24 27/var(--tw-text-opacity,1))}.dark\:text-zinc-900\/90:is(.dark *){color:#18181be6}.dark\:decoration-zinc-500:is(.dark *){text-decoration-color:#71717a}.dark\:ring-white\/10:is(.dark *){--tw-ring-color:#ffffff1a}.dark\:ring-white\/5:is(.dark *){--tw-ring-color:#ffffff0d}.dark\:ring-zinc-600:is(.dark *){--tw-ring-opacity:1;--tw-ring-color:rgb(82 82 91/var(--tw-ring-opacity,1))}.dark\:ring-zinc-900:is(.dark *){--tw-ring-opacity:1;--tw-ring-color:rgb(24 24 27/var(--tw-ring-opacity,1))}.dark\:file\:bg-brand-light:is(.dark *)::file-selector-button{background-color:var(--accent-light,#5eead4)}.dark\:file\:text-zinc-900:is(.dark *)::file-selector-button{--tw-text-opacity:1;color:rgb(24 24 27/var(--tw-text-opacity,1))}.dark\:placeholder\:text-zinc-500:is(.dark *)::-moz-placeholder{--tw-text-opacity:1;color:rgb(113 113 122/var(--tw-text-opacity,1))}.dark\:placeholder\:text-zinc-500:is(.dark *)::placeholder{--tw-text-opacity:1;color:rgb(113 113 122/var(--tw-text-opacity,1))}.dark\:hover\:border-white\/20:hover:is(.dark *){border-color:#fff3}.dark\:hover\:bg-zinc-600\/50:hover:is(.dark *){background-color:#52525b80}.dark\:hover\:bg-zinc-700:hover:is(.dark *){--tw-bg-opacity:1;background-color:rgb(63 63 70/var(--tw-bg-opacity,1))}.dark\:hover\:bg-zinc-700\/60:hover:is(.dark *){background-color:#3f3f4699}.dark\:hover\:bg-zinc-800\/40:hover:is(.dark *){background-color:#27272a66}.dark\:hover\:bg-zinc-800\/50:hover:is(.dark *){background-color:#27272a80}.dark\:hover\:bg-zinc-800\/60:hover:is(.dark *){background-color:#27272a99}.dark\:hover\:bg-zinc-800\/70:hover:is(.dark *){background-color:#27272ab3}.dark\:hover\:bg-zinc-800\/80:hover:is(.dark *){background-color:#27272acc}.dark\:hover\:text-amber-100:hover:is(.dark *){--tw-text-opacity:1;color:rgb(254 243 199/var(--tw-text-opacity,1))}.dark\:hover\:text-emerald-100:hover:is(.dark *){--tw-text-opacity:1;color:rgb(209 250 229/var(--tw-text-opacity,1))}.dark\:hover\:text-red-300:hover:is(.dark *){--tw-text-opacity:1;color:rgb(252 165 165/var(--tw-text-opacity,1))}.dark\:hover\:text-zinc-200:hover:is(.dark *){--tw-text-opacity:1;color:rgb(228 228 231/var(--tw-text-opacity,1))}.dark\:hover\:text-zinc-300:hover:is(.dark *){--tw-text-opacity:1;color:rgb(212 212 216/var(--tw-text-opacity,1))}.dark\:hover\:text-zinc-50:hover:is(.dark *){--tw-text-opacity:1;color:rgb(250 250 250/var(--tw-text-opacity,1))}.dark\:hover\:decoration-zinc-300:hover:is(.dark *){text-decoration-color:#d4d4d8}@media (width>=640px){.sm\:col-span-2{grid-column:span 2/span 2}.sm\:block{display:block}.sm\:inline-block{display:inline-block}.sm\:inline{display:inline}.sm\:basis-auto{flex-basis:auto}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}}@media (width>=768px){.md\:flex{display:flex}}@media (width>=1024px){.lg\:block{display:block}.lg\:inline{display:inline}.lg\:hidden{display:none}.lg\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.lg\:grid-cols-\[1fr_19rem\]{grid-template-columns:1fr 19rem}}@media (width>=1280px){.xl\:inline{display:inline}.xl\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (width>=1536px){.\32 xl\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.\32 xl\:grid-cols-5{grid-template-columns:repeat(5,minmax(0,1fr))}} |
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is too big to display
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
Found 2 instances
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
Minified code
QualityThis package contains minified code. This may be harmless in some cases where minified code is included in packaged libraries, however packages on npm should not minify code.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
Found 3 instances
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
Minified code
QualityThis package contains minified code. This may be harmless in some cases where minified code is included in packaged libraries, however packages on npm should not minify code.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
2568474
8.35%75
7.14%21015
19.21%96
17.07%83
20.29%