@crievo/omni 0.1.2 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -72,7 +72,7 @@
72
72
  node tools/omnichat.mjs webhook setup | subscribe | status | serve [--port 5434]
73
73
  the messaging webhook that the button and the follow gate need: `setup` creates the verify token and says
74
74
  what to paste in the Meta panel, `subscribe` signs the account up to messages and postbacks, `serve` listens
75
- (behind a tunnel: cloudflared tunnel --url http://127.0.0.1:5434)
75
+ here for a test (the Callback URL is the cloud Worker's /webhook)
76
76
  node tools/omnichat.mjs cloud status | push [videos/<slug>] [--records] [--force] | pull videos/<slug>
77
77
  the Worker in cloud/omnichat (cloud.url in the config, OMNICHAT_CLOUD_SECRET in the .env):
78
78
  `push` sends the config, the Instagram account and the rules (with --records, the local records too,
@@ -1284,9 +1284,8 @@ async function cloudCmd(sub) {
1284
1284
  /* --- the messaging webhook: the button's tap and the follow gate ------------------------------
1285
1285
  The comment's DM opens with a button (rule.button / rule.require_follow). The tap reaches here as a
1286
1286
  `messaging_postbacks` event; handleTap (tools/lib/omnichat/gate.mjs) checks the criterion and sends the
1287
- link, or asks to follow. Meta needs a public HTTPS callback URL: `serve` listens on this machine and a
1288
- tunnel (cloudflared) exposes it while the Mac is awake. A webhook handled elsewhere (a cloud function)
1289
- uses the same handleWebhook. The record is written under the pass lock, so the agent and the tap
1287
+ link, or asks to follow. Meta needs a public HTTPS callback URL: the cloud Worker's /webhook
1288
+ (cloud/omnichat), which runs the same handleWebhook; `serve` listens on this machine for a test. The record is written under the pass lock, so the agent and the tap
1290
1289
  don't overwrite each other. */
1291
1290
  const WEBHOOK_PORT = 5434;
1292
1291
  const gatedRules = () => videosWithRule().filter(d => gateOn(ruleOf(d) ?? {}));
@@ -1337,10 +1336,9 @@ async function webhook(sub) {
1337
1336
  console.log("✓ INSTAGRAM_WEBHOOK_VERIFY_TOKEN no .env");
1338
1337
  console.log(`
1339
1338
  Faltam estes passos, uma vez só:
1340
- 1. Expor a porta ${port}: node tools/omnichat.mjs webhook serve e, em outro terminal,
1341
- cloudflared tunnel --url http://127.0.0.1:${port} (anote a URL https://….trycloudflare.com)
1339
+ 1. A cloud no ar (cloud/omnichat, reference/omnichat.md): é ela que recebe o webhook.
1342
1340
  2. developers.facebook.com/apps → seu app → Instagram → API setup with Instagram login → Configure webhooks:
1343
- Callback URL: <a URL do túnel>/webhook
1341
+ Callback URL: ${cloudSettings().url || "https://omnichat.<você>.workers.dev"}/webhook
1344
1342
  Verify token: ${token}
1345
1343
  e ligue os campos messages e messaging_postbacks.
1346
1344
  3. node tools/omnichat.mjs webhook subscribe (inscreve a conta nesses campos)
package/tools/package.mjs CHANGED
@@ -7,8 +7,9 @@
7
7
  not Bun; ffmpeg, the Chrome without a window and Python are fetched by `omni up` (tools/lib/deps.mjs).
8
8
 
9
9
  node tools/package.mjs [--version <x.y.z>] builds and packs; prints the .tgz path
10
- node tools/package.mjs publish [--version …] builds, packs and publishes to npm with the account in
11
- NPM_CONFIG_USERCONFIG (e.g. ~/.npmrc-pedroflp)
10
+ node tools/package.mjs publish [--version …] builds, packs and publishes to npm: in CI through npm's
11
+ trusted publishing (.github/workflows/deploy.yml), by
12
+ hand with the account in NPM_CONFIG_USERCONFIG
12
13
 
13
14
  Try it before publishing: npm install --prefix /tmp/omni-test <the .tgz>, then
14
15
  /tmp/omni-test/node_modules/.bin/omni status
@@ -120,7 +121,8 @@ const tgz = join(OUT, packed.stdout.trim().split("\n").at(-1) ?? "");
120
121
  console.log(`${PACKAGE}@${v}: ${(size(PKG) / 1e6).toFixed(1)} MB de arquivos, pacote em ${tgz}`);
121
122
 
122
123
  if (args[0] === "publish") {
123
- if (!process.env.NPM_CONFIG_USERCONFIG)
124
+ /* CI publishes through npm's trusted publishing (OIDC); by hand, with the account's own login file */
125
+ if (!process.env.NPM_CONFIG_USERCONFIG && !process.env.GITHUB_ACTIONS)
124
126
  fail("defina NPM_CONFIG_USERCONFIG com o login da conta que publica (ex.: ~/.npmrc-pedroflp)");
125
127
  const r = spawnSync("npm", ["publish", tgz, "--access", "public", ...(v.includes("-") ? ["--tag", "next"] : [])], {
126
128
  stdio: "inherit",
package/tools/publish.mjs CHANGED
@@ -66,7 +66,7 @@
66
66
 
67
67
  /* The platform clients (tools/lib/platforms/), the texts and per-platform checks (tools/lib/publish/)
68
68
  and the schedule decisions (tools/lib/schedule/) are the core; this file is the CLI: files, flags,
69
- the desktop login, the local tunnel for Instagram, ffmpeg/ffprobe, `claude` and the launchd agent. */
69
+ the desktop login, the Instagram files through the cloud's R2, ffmpeg/ffprobe, `claude` and the launchd agent. */
70
70
 
71
71
  import {
72
72
  readFileSync,
@@ -74,7 +74,6 @@ import {
74
74
  renameSync,
75
75
  existsSync,
76
76
  statSync,
77
- createReadStream,
78
77
  readdirSync,
79
78
  mkdirSync,
80
79
  openSync,
@@ -83,9 +82,8 @@ import {
83
82
  unlinkSync,
84
83
  } from "node:fs";
85
84
  import { createServer } from "node:http";
86
- import https from "node:https";
87
85
  import { randomBytes, createHash } from "node:crypto";
88
- import { spawn, spawnSync } from "node:child_process";
86
+ import { spawnSync } from "node:child_process";
89
87
  import { homedir, tmpdir } from "node:os";
90
88
  import { join, resolve, dirname, basename, sep } from "node:path";
91
89
  import { fileURLToPath } from "node:url";
@@ -98,7 +96,6 @@ import {
98
96
  pullTiktokEnv,
99
97
  cloudSettings,
100
98
  cloudApi,
101
- sleep,
102
99
  instagramToken,
103
100
  instagramAccount,
104
101
  igClient,
@@ -303,138 +300,44 @@ async function authInstagram(token) {
303
300
  console.log(`Instagram conectado: @${c.username}`);
304
301
  }
305
302
 
306
- /* does the URL answer (206) when resolved through Cloudflare's DNS (1.1.1.1)? */
307
- async function reachableFromOutside(url, host) {
308
- let ip;
309
- try {
310
- const r = await fetch(`https://1.1.1.1/dns-query?name=${host}&type=A`, {
311
- headers: { accept: "application/dns-json" },
312
- signal: AbortSignal.timeout(5000),
313
- });
314
- ip = (await r.json()).Answer?.find(a => a.type === 1)?.data;
315
- } catch {
316
- return false;
317
- }
318
- if (!ip) return false;
319
- return new Promise(ok => {
320
- const req = https.get(
321
- url,
322
- {
323
- headers: { range: "bytes=0-0" },
324
- lookup: (_h, opts, cb) => (opts?.all ? cb(null, [{ address: ip, family: 4 }]) : cb(null, ip, 4)),
325
- timeout: 8000,
326
- },
327
- res => {
328
- res.resume();
329
- ok(res.statusCode === 206 || res.statusCode === 200);
330
- },
303
+ /* Files at a public URL until Instagram has them: each one goes up to the cloud's R2 (cloud/omnichat,
304
+ PUT /api/media/<slug>/<name>) and Instagram downloads it from <cloud>/media/<key>. `urls` in file
305
+ order; `close()` deletes them (the bucket's 7-day rule is the backstop). The schedule uses the same
306
+ bucket (ADR 0004). */
307
+ async function servePublic(slug, files) {
308
+ const c = cloudSettings();
309
+ if (!c.ready)
310
+ throw new Error(
311
+ "o Instagram baixa o vídeo de um link público, que vem da cloud: falta cloud.url no config e OMNICHAT_CLOUD_SECRET no .env",
331
312
  );
332
- req.on("timeout", () => {
333
- req.destroy();
334
- ok(false);
335
- });
336
- req.on("error", () => ok(false));
337
- });
338
- }
339
-
340
- /* Files at a public URL for a few minutes: a server on 127.0.0.1 that only answers
341
- /<token>.<ext> for each one (with Range) and a cloudflared quick tunnel in front
342
- (https://<random>.trycloudflare.com). `urls` in file order; `close()` tears both down.
343
- Local only: in the cloud the files are already at a public URL (object storage). */
344
- const TYPES = { ".mp4": "video/mp4", ".jpg": "image/jpeg" };
345
- async function servePublic(files) {
346
- const served = new Map(
347
- files.map(file => {
348
- const ext = file.slice(file.lastIndexOf("."));
349
- return [
350
- `/${randomBytes(16).toString("hex")}${ext}`,
351
- { file, size: statSync(file).size, type: TYPES[ext] ?? "application/octet-stream" },
352
- ];
353
- }),
354
- );
355
- const srv = createServer((req, res) => {
356
- const s = served.get(req.url);
357
- if (!s || !["GET", "HEAD"].includes(req.method)) {
358
- res.writeHead(404).end();
359
- return;
360
- }
361
- const { file, size } = s;
362
- let start = 0,
363
- end = size - 1,
364
- code = 200;
365
- const m = /^bytes=(\d*)-(\d*)$/.exec(req.headers.range ?? "");
366
- if (m && (m[1] || m[2])) {
367
- code = 206;
368
- if (m[1]) {
369
- start = +m[1];
370
- if (m[2]) end = Math.min(+m[2], size - 1);
371
- } else start = Math.max(0, size - +m[2]);
372
- if (start > end) {
373
- res.writeHead(416, { "content-range": `bytes */${size}` }).end();
374
- return;
375
- }
376
- }
377
- res.writeHead(code, {
378
- "content-type": s.type,
379
- "content-length": String(end - start + 1),
380
- "accept-ranges": "bytes",
381
- ...(code === 206 ? { "content-range": `bytes ${start}-${end}/${size}` } : {}),
382
- });
383
- if (req.method === "HEAD") res.end();
384
- else createReadStream(file, { start, end }).pipe(res);
385
- });
386
- await new Promise(ok => srv.listen(0, "127.0.0.1", ok));
387
- const tunnel = spawn(
388
- P.instagram.cloudflared ?? "cloudflared",
389
- ["tunnel", "--no-autoupdate", "--url", `http://127.0.0.1:${srv.address().port}`],
390
- { stdio: ["ignore", "pipe", "pipe"] },
391
- );
392
- const close = () => {
393
- try {
394
- tunnel.kill();
395
- } catch {
396
- /* already exited */
397
- }
398
- srv.close();
313
+ /* the Worker takes [\w-]+ for the slug and [\w-]+.(mp4|jpg) for the name */
314
+ const safe = (/** @type {string} */ s) => s.replace(/[^\w-]+/g, "-");
315
+ const nameOf = (/** @type {string} */ file) => {
316
+ const b = basename(file);
317
+ const dot = b.lastIndexOf(".");
318
+ return `${safe(b.slice(0, dot))}${b.slice(dot).toLowerCase()}`;
399
319
  };
320
+ const keys = [];
321
+ const close = () =>
322
+ Promise.all(
323
+ keys.map(k => cloudApi("DELETE", `/api/media/${k}`).catch(e => warn(`não apaguei ${k} da cloud: ${e.message}`))),
324
+ );
400
325
  try {
401
- /* the address shows up in the output right away; the registered connection a few seconds later */
402
- let output = "";
403
- const waitFor = (re, label) =>
404
- new Promise((ok, reject) => {
405
- const check = () => {
406
- const m = re.exec(output);
407
- if (m) ok(m[0]);
408
- };
409
- tunnel.stdout.on("data", check);
410
- tunnel.stderr.on("data", check);
411
- check();
412
- tunnel.on("error", e => reject(new Error(`cloudflared: ${e.message} (instale com brew install cloudflared)`)));
413
- tunnel.on("exit", c => reject(new Error(`o cloudflared saiu com ${c}: ${output.trim().slice(-300)}`)));
414
- setTimeout(() => reject(new Error(`o cloudflared não ${label} em 30 s`)), 30_000).unref();
326
+ const urls = [];
327
+ for (const file of files) {
328
+ const r = await fetch(`${c.url}/api/media/${safe(slug)}/${nameOf(file)}`, {
329
+ method: "PUT",
330
+ headers: { authorization: `Bearer ${env("OMNICHAT_CLOUD_SECRET")}` },
331
+ body: readFileSync(file),
415
332
  });
416
- tunnel.stdout.on("data", b => {
417
- output += b;
418
- });
419
- tunnel.stderr.on("data", b => {
420
- output += b;
421
- });
422
- const base = await waitFor(/https:\/\/[a-z0-9-]+\.trycloudflare\.com/, "abriu o túnel");
423
- await waitFor(/Registered tunnel connection/, "conectou o túnel");
424
- /* The new name takes one to two minutes to answer. The proof is downloading 1 byte from
425
- the public URL, with the IP taken from a public DNS: macOS's DNS caches the "does not
426
- exist" of the first attempt and would delay everything. */
427
- const urls = [...served.keys()].map(path => `${base}${path}`);
428
- const host = new URL(base).hostname;
429
- const deadline = Date.now() + 150_000;
430
- for (;;) {
431
- if (await reachableFromOutside(urls[0], host)) break;
432
- if (Date.now() > deadline) throw new Error(`o túnel abriu, mas ${host} não respondeu em 2,5 min`);
433
- await sleep(3000);
333
+ const j = await r.json().catch(() => ({}));
334
+ if (!r.ok || !j.key) throw new Error(`subir ${basename(file)} para a cloud: ${r.status} ${j.error ?? ""}`.trim());
335
+ keys.push(j.key);
336
+ urls.push(j.url ?? `${c.url}/media/${j.key}`);
434
337
  }
435
338
  return { urls, close };
436
339
  } catch (e) {
437
- close();
340
+ await close();
438
341
  throw e;
439
342
  }
440
343
  }
@@ -443,12 +346,12 @@ async function servePublic(files) {
443
346
  video_url (resumable upload is only for apps with Facebook login). The link comes from
444
347
  servePublic and stays up until Instagram finishes processing. The cover goes the same
445
348
  way, as cover_url, instead of the cover_at_s frame. */
446
- async function uploadInstagram(mp4, caption, cover) {
349
+ async function uploadInstagram(slug, mp4, caption, cover) {
447
350
  const { token, id } = await instagramToken();
448
351
  console.log(
449
- `Instagram: abrindo um link temporário para o Instagram baixar o vídeo (${(statSync(mp4).size / 1e6).toFixed(1)} MB)…`,
352
+ `Instagram: subindo o vídeo para a cloud, de onde o Instagram baixa (${(statSync(mp4).size / 1e6).toFixed(1)} MB)…`,
450
353
  );
451
- const link = await servePublic(cover ? [mp4, cover.jpg] : [mp4]);
354
+ const link = await servePublic(slug, cover ? [mp4, cover.jpg] : [mp4]);
452
355
  const [videoUrl, coverUrl] = link.urls;
453
356
  return publishReel({
454
357
  client: igClient(token),
@@ -809,7 +712,7 @@ async function publish(dir) {
809
712
  plat === "youtube"
810
713
  ? await uploadYoutube(exp.mp4, meta, privacy, video, cover)
811
714
  : plat === "instagram"
812
- ? await uploadInstagram(exp.mp4, meta.caption, cover)
715
+ ? await uploadInstagram(basename(dir), exp.mp4, meta.caption, cover)
813
716
  : await uploadTiktok(exp.mp4, meta.tiktokCaption, video, tiktokPrivacy);
814
717
  pub[plat] = publishedEntry({
815
718
  result: r,
@@ -899,8 +802,8 @@ async function publishCarousel(videoDir) {
899
802
 
900
803
  try {
901
804
  const { token, id } = await instagramToken();
902
- console.log(`Instagram: abrindo um link temporário para o Instagram baixar os ${jpgs.length} slides…`);
903
- const link = await servePublic(jpgs);
805
+ console.log(`Instagram: subindo os ${jpgs.length} slides para a cloud, de onde o Instagram baixa…`);
806
+ const link = await servePublic(basename(videoDir), jpgs);
904
807
  const done = await publishCarouselPost({
905
808
  client: igClient(token),
906
809
  userId: id,
@@ -1361,7 +1264,7 @@ function runOne(dir) {
1361
1264
 
1362
1265
  /* --- the launchd agent ---------------------------------------------------------------
1363
1266
  Runs `scheduler run` every 60 s with the node that installed it. PATH goes along because
1364
- launchd starts with a minimal PATH, without ffprobe, cloudflared or claude. */
1267
+ launchd starts with a minimal PATH, without ffprobe or claude. */
1365
1268
  const domain = () => `gui/${process.getuid()}`;
1366
1269
  const launchctl = (...a) => spawnSync("launchctl", a, { encoding: "utf8" });
1367
1270
 
@@ -34,7 +34,7 @@
34
34
  guia reproduz a peça, e não só a descreve.
35
35
 
36
36
  Ritmo não se sente em quadro parado, mas se mede: é o que este script dá.
37
- Requer ffmpeg/ffprobe; yt-dlp para baixar de link.
37
+ Requer ffmpeg/ffprobe; yt-dlp para baixar de link (o omni traz os três em ~/.omni/bin).
38
38
  ============================================================================ */
39
39
 
40
40
  import {
package/tools/setup.mjs CHANGED
@@ -1,8 +1,9 @@
1
1
  #!/usr/bin/env node
2
- /* Prepares a fresh OmniVideo clone and checks whether the machine is ready for
3
- the first video. The full walkthrough (with the provider accounts) is in
4
- docs/onboarding.md and in the studio's Getting started page
5
- (/getting-started), which uses this same check.
2
+ /* Prepares a fresh OmniVideo clone (the development repository) and checks
3
+ whether the machine is ready for the first video. A plugin install doesn't use
4
+ it: `omni up` prepares the computer, and the studio's Getting started page
5
+ (/getting-started, docs/onboarding.md) measures its own steps
6
+ (studio/api/services/onboarding.ts).
6
7
 
7
8
  node tools/setup.mjs prepares what's missing and checks everything
8
9
  node tools/setup.mjs check [--json] only checks (the UI reads --json)
@@ -89,7 +90,8 @@ const version = (cmd, a = ["--version"]) => {
89
90
  };
90
91
 
91
92
  /* --- check ------------------------------------------------------------------
92
- One step per guide section (the id is the `<!-- step: id -->` in docs/onboarding.md).
93
+ The development repository's steps (machine, project, Claude, voice, style, sound…), printed in the
94
+ terminal; the Getting started page has its own.
93
95
  state: ok · missing (blocks the first video) · warning (works, but take a look) */
94
96
  function check() {
95
97
  const c = cfg();
@@ -207,7 +209,7 @@ function check() {
207
209
  ready.length ? "ok" : "missing",
208
210
  ready.length
209
211
  ? `${ready.length} de ${accounts.length} conta(s) com login`
210
- : `rode ${accounts[0] ? loginCommand(accounts[0], cfg) : "CLAUDE_CONFIG_DIR=~/.claude-personal claude auth login"}`,
212
+ : `rode ${accounts[0] ? loginCommand(accounts[0], cfg) : "claude auth login"}`,
211
213
  ),
212
214
  ...accounts
213
215
  .filter(a => !loginOf(a) && a !== accounts[0])
@@ -328,17 +330,12 @@ function check() {
328
330
  /* 8. publish (optional) */
329
331
  {
330
332
  const has = (...ks) => ks.every(k => env.has(k));
331
- const cloudflared = version("cloudflared");
333
+ /* Instagram downloads the video from the cloud's R2 (publish.mjs servePublic) */
334
+ const cloud = !!(c.cloud?.url || c.omnichat?.cloud?.url) && env.has("OMNICHAT_CLOUD_SECRET");
332
335
  steps.push({
333
336
  id: "publish",
334
337
  optional: true,
335
338
  items: [
336
- item(
337
- "cloudflared",
338
- "cloudflared (o Instagram busca o vídeo por ele)",
339
- cloudflared ? "ok" : "warning",
340
- cloudflared ? cloudflared.split(" (")[0] : "opcional: só para publicar no Instagram",
341
- ),
342
339
  item(
343
340
  "youtube",
344
341
  "YouTube",
@@ -348,8 +345,12 @@ function check() {
348
345
  item(
349
346
  "instagram",
350
347
  "Instagram",
351
- has("INSTAGRAM_TOKEN", "INSTAGRAM_USER_ID") ? "ok" : "warning",
352
- has("INSTAGRAM_TOKEN") ? "conectado" : "opcional: não conectado",
348
+ has("INSTAGRAM_TOKEN", "INSTAGRAM_USER_ID") && cloud ? "ok" : "warning",
349
+ !has("INSTAGRAM_TOKEN")
350
+ ? "opcional: não conectado"
351
+ : cloud
352
+ ? "conectado"
353
+ : "conectado, mas o Instagram baixa o vídeo da cloud: falta cloud.url no config ou OMNICHAT_CLOUD_SECRET no .env",
353
354
  ),
354
355
  item(
355
356
  "tiktok",