samograph 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +21 -5
  2. package/dist/cli.js +746 -294
  3. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -36,7 +36,7 @@ var __commonJS = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports,
36
36
  var require_package = __commonJS((exports, module) => {
37
37
  module.exports = {
38
38
  name: "samograph",
39
- version: "0.6.0",
39
+ version: "0.7.0",
40
40
  description: "Let AI agents join Zoom and Google Meet calls as active participants.",
41
41
  type: "module",
42
42
  license: "Apache-2.0",
@@ -174,7 +174,15 @@ function newPresenceSnapshot(state = "listening", message = defaultPresenceMessa
174
174
  state,
175
175
  message,
176
176
  updated_at: new Date().toISOString(),
177
- activities
177
+ activities,
178
+ chime: null
179
+ };
180
+ }
181
+ function withChime(snapshot) {
182
+ return {
183
+ ...snapshot,
184
+ updated_at: new Date().toISOString(),
185
+ chime: { at: new Date().toISOString() }
178
186
  };
179
187
  }
180
188
  var ACTIVITY_LIMIT = 16;
@@ -424,6 +432,9 @@ function presencePageHtml() {
424
432
  overflow: hidden;
425
433
  display: flex;
426
434
  flex-direction: column;
435
+ /* Pin the stack to the top: the first line appears at the top and each
436
+ new line is added below it (newest at the bottom, chat order). Once the
437
+ lane is full, the oldest lines overflow off the top. */
427
438
  justify-content: flex-start;
428
439
  }
429
440
  .item {
@@ -709,6 +720,54 @@ function presencePageHtml() {
709
720
  resize();
710
721
  requestAnimationFrame(draw);
711
722
  }
723
+ // Soft "blip" audio cue, synthesized with WebAudio (no asset files). Played
724
+ // once per new chime timestamp \u2014 e.g. when the bot posts a meeting-chat
725
+ // message \u2014 so people notice it without watching the camera. Recall renders
726
+ // this page as the bot camera and streams its audio into the call.
727
+ let audioCtx = null;
728
+ function chimeAudioContext() {
729
+ if (!audioCtx) {
730
+ const Ctx = window.AudioContext || window.webkitAudioContext;
731
+ if (!Ctx) return null;
732
+ try { audioCtx = new Ctx(); } catch { return null; }
733
+ }
734
+ if (audioCtx.state === "suspended") audioCtx.resume().catch(() => {});
735
+ return audioCtx;
736
+ }
737
+ function playChime() {
738
+ const ctx = chimeAudioContext();
739
+ if (!ctx) return;
740
+ const t = ctx.currentTime;
741
+ const osc = ctx.createOscillator();
742
+ const gain = ctx.createGain();
743
+ const lp = ctx.createBiquadFilter();
744
+ lp.type = "lowpass";
745
+ lp.frequency.value = 1800; // shave harshness for a soft tone
746
+ osc.type = "sine";
747
+ // A gentle upward "bl-ip" rather than a flat beep.
748
+ osc.frequency.setValueAtTime(540, t);
749
+ osc.frequency.exponentialRampToValueAtTime(680, t + 0.06);
750
+ // Quick soft attack, smooth decay; low peak gain so it stays unobtrusive.
751
+ gain.gain.setValueAtTime(0.0001, t);
752
+ gain.gain.exponentialRampToValueAtTime(0.16, t + 0.012);
753
+ gain.gain.exponentialRampToValueAtTime(0.0001, t + 0.28);
754
+ osc.connect(lp);
755
+ lp.connect(gain);
756
+ gain.connect(ctx.destination);
757
+ osc.start(t);
758
+ osc.stop(t + 0.32);
759
+ }
760
+ let lastChimeAt = "";
761
+ let chimeReady = false;
762
+ function handleChime(chime) {
763
+ const at = chime && chime.at ? String(chime.at) : "";
764
+ // First poll establishes a baseline without playing, so a chime already
765
+ // sitting in the snapshot at page load does not fire on join.
766
+ if (!chimeReady) { lastChimeAt = at; chimeReady = true; return; }
767
+ if (!at || at === lastChimeAt) return;
768
+ lastChimeAt = at;
769
+ playChime();
770
+ }
712
771
  function formatUpdated(value) {
713
772
  const date = new Date(String(value || ""));
714
773
  if (Number.isNaN(date.getTime())) return "Waiting for live signal";
@@ -740,8 +799,10 @@ function presencePageHtml() {
740
799
  element.append(empty);
741
800
  return;
742
801
  }
802
+ // items arrive newest-first; render oldest-first so the newest line lands
803
+ // at the bottom of the lane (chat order). Keep only the newest 14.
743
804
  let lastLabel = "";
744
- for (const item of items.slice(0, 14)) {
805
+ for (const item of items.slice(0, 14).reverse()) {
745
806
  const row = document.createElement("div");
746
807
  row.className = "item";
747
808
  const label = document.createElement("div");
@@ -757,6 +818,12 @@ function presencePageHtml() {
757
818
  element.append(row);
758
819
  }
759
820
  }
821
+ // Adaptive polling: every poll is a request through the public tunnel
822
+ // (the page is loaded by Recall via the tunnel URL, so same-origin
823
+ // fetches ride it too). Poll fast only while the snapshot is changing;
824
+ // back off when the call goes quiet to preserve tunnel request quota.
825
+ let lastSignature = "";
826
+ let lastActivityAt = Date.now();
760
827
  async function refresh() {
761
828
  try {
762
829
  const response = await fetch("/presence.json", {
@@ -765,6 +832,12 @@ function presencePageHtml() {
765
832
  });
766
833
  if (!response.ok) return;
767
834
  const data = await response.json();
835
+ handleChime(data.chime);
836
+ const signature = String(data.updated_at || "");
837
+ if (signature !== lastSignature) {
838
+ lastSignature = signature;
839
+ lastActivityAt = Date.now();
840
+ }
768
841
  const state = String(data.state || "listening");
769
842
  document.getElementById("live").textContent = state;
770
843
  const buckets = { heard: [], comment: [] };
@@ -787,23 +860,33 @@ function presencePageHtml() {
787
860
  document.documentElement.style.setProperty("--accent-mid", pair[2]);
788
861
  } catch {}
789
862
  }
863
+ function nextPollDelay() {
864
+ // 1 s while the snapshot changed within the last 30 s, else 5 s.
865
+ return Date.now() - lastActivityAt < 30000 ? 1000 : 5000;
866
+ }
867
+ async function pollLoop() {
868
+ await refresh();
869
+ setTimeout(pollLoop, nextPollDelay());
870
+ }
790
871
  if (backgroundMode === "robot") {
791
872
  initRobot();
792
873
  } else {
793
874
  initPlasma();
794
875
  initFpsProbe();
795
- refresh();
796
- setInterval(refresh, 1000);
797
876
  }
877
+ // Poll on every background, including the static robot avatar: the loop
878
+ // drives the chime cue and live activity lanes, which must not depend on
879
+ // the animated plasma backgrounds being active.
880
+ pollLoop();
798
881
  </script>
799
882
  </body>
800
883
  </html>`;
801
884
  }
802
885
 
803
886
  // src/commands/join.ts
804
- import { writeFileSync as writeFileSync4 } from "fs";
805
- import { spawn as spawnChild } from "child_process";
806
- import { randomUUID } from "crypto";
887
+ import { writeFileSync as writeFileSync5 } from "fs";
888
+ import { spawn as spawnChild2 } from "child_process";
889
+ import { randomUUID as randomUUID2 } from "crypto";
807
890
  import { fileURLToPath as fileURLToPath2 } from "url";
808
891
 
809
892
  // src/transcript.ts
@@ -1284,6 +1367,14 @@ function makeRecallClient(fetchFn = fetch) {
1284
1367
  signal: AbortSignal.timeout(1e4)
1285
1368
  });
1286
1369
  },
1370
+ async outputAudio(botId, b64Mp3) {
1371
+ return fetchFn(`${RECALL_BASE}/bot/${botId}/output_audio/`, {
1372
+ method: "POST",
1373
+ headers: headers(),
1374
+ body: JSON.stringify({ kind: "mp3", b64_data: b64Mp3 }),
1375
+ signal: AbortSignal.timeout(15000)
1376
+ });
1377
+ },
1287
1378
  async screenshot(botId) {
1288
1379
  return fetchFn(`${RECALL_BASE}/bot/${botId}/screenshot/`, {
1289
1380
  method: "GET",
@@ -1308,20 +1399,501 @@ function makeRecallClient(fetchFn = fetch) {
1308
1399
  };
1309
1400
  }
1310
1401
 
1402
+ // src/introText.ts
1403
+ var DEFAULT_INTRO_TEXT = "Hi, I'm Leo, a samograph assistant bot \uD83E\uDD16 During the call I follow the " + "live transcript and can help in real time: post messages here in chat (with " + "a soft chime), show my status on the bot camera, capture the screen-share or " + "a participant's video on request, and keep shared notes in a Google Doc. " + "Just mention me in chat if you need anything.";
1404
+
1405
+ // src/chimeAudio.ts
1406
+ var CHIME_MP3_BASE64 = "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" + "qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqr/+5LE8IIaGgj0Dr0rivbA3uHGIbmqqqqqqqqqqqqqqqqqqqqqqqqqqqqq" + "qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqoAUakj" + "rtRWckiBjGZ5j8FpxFKFGjBLBRAkGlws9jjfBoPkCNAuw9yFEVSmkqNDIyURwtJeDb2VlVlUl1INRrc///2Mk4T1mUZKlipwo25q" + "KypYqVKI4XSaU55v/919/+soipVRGw/NjKVw/xEQlji7DeayiVGSiNh7pKrLKpTpUiEI0NF0E6VLHShRGw9mSqS88ldbn//yUa+Z" + "KMooiERB8PkE83JRlV16ismklOG5sZXcNzclFZaxKqIFpd9NTEFNRTMuMTAwVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVV" + "VVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVV" + "//uSxJgD17XquExlJsgAADSAAAAEVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVV" + "VVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVV" + "VVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVV" + "VVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVV" + "VVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVV" + "VVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVQ==";
1407
+
1408
+ // src/commands/chat.ts
1409
+ async function ringCallAudio(recall, botId) {
1410
+ try {
1411
+ const resp = await recall.outputAudio(botId, CHIME_MP3_BASE64);
1412
+ if (!resp.ok)
1413
+ await resp.text().catch(() => "");
1414
+ } catch {}
1415
+ }
1416
+ async function ringChime(fetchFn) {
1417
+ try {
1418
+ const state = loadState();
1419
+ const presenceUrl = state.local_presence_update_url;
1420
+ const token = state.presence_write_token;
1421
+ if (typeof presenceUrl !== "string" || !presenceUrl)
1422
+ return;
1423
+ if (typeof token !== "string" || !token)
1424
+ return;
1425
+ const u = new URL(presenceUrl);
1426
+ u.pathname = "/chime";
1427
+ await fetchFn(u.toString(), {
1428
+ method: "POST",
1429
+ headers: { "X-Samograph-Presence-Token": token }
1430
+ });
1431
+ } catch {}
1432
+ }
1433
+ async function cmdChat(args, deps = {}) {
1434
+ const recall = deps.recall ?? makeRecallClient();
1435
+ const bid = botIdFromArgsOrState(args.bot_id);
1436
+ const resp = await recall.sendChat(bid, args.message ?? "");
1437
+ if (!resp.ok) {
1438
+ const body = await resp.text().catch(() => "");
1439
+ throw new Error(`send_chat_message failed: ${resp.status} ${body}`);
1440
+ }
1441
+ await ringCallAudio(recall, bid);
1442
+ await ringChime(deps.fetchFn ?? fetch);
1443
+ process.stdout.write(`Sent: ${args.message}
1444
+ `);
1445
+ }
1446
+
1447
+ // src/commands/intro.ts
1448
+ var UTTERANCE_RE = /^\[[^\]]+\]\s+([^:]+):\s*(.*)$/;
1449
+ function firstHeardLine(path) {
1450
+ for (const line of localTranscriptLines(path)) {
1451
+ const m = line.match(UTTERANCE_RE);
1452
+ if (!m)
1453
+ continue;
1454
+ const speaker = m[1].trim();
1455
+ const text = m[2].trim();
1456
+ if (!text || speaker.startsWith("SAMOGRAPH-WARNING"))
1457
+ continue;
1458
+ return { speaker, text };
1459
+ }
1460
+ return null;
1461
+ }
1462
+ function buildIntroMessage(args, transcriptPath) {
1463
+ const custom = args.intro_text?.trim();
1464
+ let message = custom && custom.length ? custom : DEFAULT_INTRO_TEXT;
1465
+ if (args.context) {
1466
+ const first = firstHeardLine(transcriptPath);
1467
+ if (first) {
1468
+ message += `
1469
+
1470
+ The first thing I heard was \u2014 ${first.speaker}: "${first.text}"`;
1471
+ }
1472
+ }
1473
+ return message;
1474
+ }
1475
+ async function cmdIntro(args, deps = {}) {
1476
+ const recall = deps.recall ?? makeRecallClient();
1477
+ const message = buildIntroMessage(args, deps.transcriptPath);
1478
+ await cmdChat({ command: "chat", message, bot_id: args.bot_id ?? null }, { recall, fetchFn: deps.fetchFn });
1479
+ }
1480
+ var IN_CALL_STATUSES = new Set([
1481
+ "in_call_recording",
1482
+ "in_call_not_recording"
1483
+ ]);
1484
+ function latestStatusCode(bot) {
1485
+ const changes = bot?.status_changes;
1486
+ if (Array.isArray(changes) && changes.length) {
1487
+ return String(changes[changes.length - 1]?.code ?? "");
1488
+ }
1489
+ return "";
1490
+ }
1491
+ var realSleep = (ms) => new Promise((r) => setTimeout(r, ms));
1492
+ async function postIntroOnJoin(recall, botId, text, opts = {}) {
1493
+ const tries = opts.tries ?? 12;
1494
+ const delayMs = opts.delayMs ?? 2500;
1495
+ const sleepFn = opts.sleepFn ?? realSleep;
1496
+ for (let i = 0;i < tries; i++) {
1497
+ try {
1498
+ const bot = await recall.getBot(botId);
1499
+ if (IN_CALL_STATUSES.has(latestStatusCode(bot))) {
1500
+ const resp = await recall.sendChat(botId, text);
1501
+ if (resp.ok) {
1502
+ process.stdout.write(`Posted intro to meeting chat.
1503
+ `);
1504
+ return true;
1505
+ }
1506
+ process.stderr.write(`Note: posting the intro returned HTTP ${resp.status}. ` + `Run 'samograph intro' to retry if it did not appear.
1507
+ `);
1508
+ return false;
1509
+ }
1510
+ } catch {}
1511
+ if (i < tries - 1)
1512
+ await sleepFn(delayMs);
1513
+ }
1514
+ process.stderr.write("Note: could not post the intro yet (bot not in the call). " + `Run 'samograph intro' once it has joined.
1515
+ `);
1516
+ return false;
1517
+ }
1518
+
1519
+ // src/server.ts
1520
+ import { appendFileSync } from "fs";
1521
+ import { readFileSync as readFileSync5 } from "fs";
1522
+ import { Buffer as Buffer3 } from "buffer";
1523
+ import { createHash, randomUUID, timingSafeEqual } from "crypto";
1524
+ var WEBHOOK_MAX_BYTES = 1024 * 1024;
1525
+ var HEALTH_MARKER = "samograph-health";
1526
+ async function probeTunnelHealth(publicBaseUrl, fetchFn = fetch, nonceFn = randomUUID) {
1527
+ const nonce = nonceFn();
1528
+ try {
1529
+ const response = await fetchFn(`${publicBaseUrl}/health?nonce=${encodeURIComponent(nonce)}`, { cache: "no-store", signal: AbortSignal.timeout(5000) });
1530
+ const headerCode = response.headers.get("ngrok-error-code");
1531
+ if (headerCode) {
1532
+ return { ok: false, ngrokErrorCode: headerCode.trim() };
1533
+ }
1534
+ if (response.ok) {
1535
+ const body = await response.json().catch(() => null);
1536
+ if (body !== null && body.nonce === nonce && body.marker === HEALTH_MARKER) {
1537
+ return { ok: true, ngrokErrorCode: null };
1538
+ }
1539
+ return { ok: false, ngrokErrorCode: null };
1540
+ }
1541
+ const text = await response.text().catch(() => "");
1542
+ const bodyCode = text.match(/ERR_NGROK_\d+/);
1543
+ return { ok: false, ngrokErrorCode: bodyCode ? bodyCode[0] : null };
1544
+ } catch {
1545
+ return { ok: false, ngrokErrorCode: null };
1546
+ }
1547
+ }
1548
+ var TUNNEL_WATCHDOG_INTERVAL_MS = 60000;
1549
+ var TUNNEL_WATCHDOG_FAILURE_THRESHOLD = 2;
1550
+ function fmtTranscriptTs(d) {
1551
+ const pad = (n) => String(n).padStart(2, "0");
1552
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ` + `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
1553
+ }
1554
+ function defaultSchedule(fn, ms) {
1555
+ const timer = setInterval(fn, ms);
1556
+ timer.unref?.();
1557
+ return { stop: () => clearInterval(timer) };
1558
+ }
1559
+ function startTunnelWatchdog(options) {
1560
+ const publicBase = (options.publicBase ?? "").replace(/\/+$/, "");
1561
+ if (!publicBase)
1562
+ return null;
1563
+ const fetchFn = options.fetch ?? fetch;
1564
+ const nonceFn = options.nonce ?? randomUUID;
1565
+ const now = options.now ?? (() => new Date);
1566
+ const writeStderr = options.stderr ?? ((s) => void process.stderr.write(s));
1567
+ let consecutiveFailures = 0;
1568
+ let inOutage = false;
1569
+ const emit = (text) => {
1570
+ const line = `[${fmtTranscriptTs(now())}] ${text}`;
1571
+ try {
1572
+ appendFileSync(options.transcriptPath, line + `
1573
+ `);
1574
+ } catch {}
1575
+ writeStderr(line + `
1576
+ `);
1577
+ };
1578
+ const tick = async () => {
1579
+ const probe = await probeTunnelHealth(publicBase, fetchFn, nonceFn);
1580
+ if (probe.ok) {
1581
+ consecutiveFailures = 0;
1582
+ if (inOutage) {
1583
+ inOutage = false;
1584
+ emit("SAMOGRAPH-WARNING: tunnel recovered - live transcript delivery resumed");
1585
+ }
1586
+ return;
1587
+ }
1588
+ consecutiveFailures += 1;
1589
+ if (consecutiveFailures >= TUNNEL_WATCHDOG_FAILURE_THRESHOLD && !inOutage) {
1590
+ inOutage = true;
1591
+ const cause = probe.ngrokErrorCode ?? "health check failed";
1592
+ emit(`SAMOGRAPH-WARNING: tunnel unreachable (${cause}) - transcript may be ` + "incomplete; rejoin with --tunnel cloudflared or --webhook-base");
1593
+ }
1594
+ };
1595
+ const scheduled = (options.schedule ?? defaultSchedule)(() => void tick(), options.intervalMs ?? TUNNEL_WATCHDOG_INTERVAL_MS);
1596
+ return { tick, stop: () => scheduled.stop() };
1597
+ }
1598
+ function tokensEqual(a, b) {
1599
+ if (!a || !b)
1600
+ return false;
1601
+ const ha = createHash("sha256").update(a).digest();
1602
+ const hb = createHash("sha256").update(b).digest();
1603
+ return timingSafeEqual(ha, hb);
1604
+ }
1605
+ async function handleWebhook(payload, transcriptPath) {
1606
+ const line = formatTranscriptLine(payload);
1607
+ if (line !== null) {
1608
+ appendFileSync(transcriptPath, line + `
1609
+ `);
1610
+ }
1611
+ return line;
1612
+ }
1613
+ function selectedFrame(latest, bySource, source) {
1614
+ const key = normalizeFrameSource(source);
1615
+ return key ? bySource.get(key) ?? { raw: null, metadata: null } : latest;
1616
+ }
1617
+ function frameInventory(bySource) {
1618
+ const seen = new Set;
1619
+ const frames = [];
1620
+ for (const frame of bySource.values()) {
1621
+ const sourceKey = frame.metadata?.source_key;
1622
+ if (!frame.metadata || !sourceKey || seen.has(sourceKey))
1623
+ continue;
1624
+ seen.add(sourceKey);
1625
+ frames.push(frame.metadata);
1626
+ }
1627
+ frames.sort((a, b) => String(a.source_key).localeCompare(String(b.source_key)));
1628
+ return frames;
1629
+ }
1630
+ function callIdFromStateFile(path) {
1631
+ if (!path)
1632
+ return null;
1633
+ try {
1634
+ const state = JSON.parse(readFileSync5(path, "utf-8"));
1635
+ return typeof state.bot_id === "string" ? state.bot_id : null;
1636
+ } catch {
1637
+ return null;
1638
+ }
1639
+ }
1640
+ function serve(port, transcriptPath, options = {}) {
1641
+ const opts = typeof options === "string" || options === null ? { webhookToken: options } : options;
1642
+ const latestVideoFrame = { raw: null, metadata: null };
1643
+ let presence = newPresenceSnapshot();
1644
+ const framesBySource = new Map;
1645
+ const frameAuthorized = (req) => tokensEqual(req.headers.get("X-Samograph-Frame-Token"), opts.frameToken);
1646
+ const presencePageAuthorized = (req, url) => tokensEqual(req.headers.get("X-Samograph-Presence-Token"), opts.presenceToken) || tokensEqual(url.searchParams.get("token"), opts.presenceToken);
1647
+ const presenceJsonAuthorized = (req) => tokensEqual(req.headers.get("X-Samograph-Presence-Token"), opts.presenceToken);
1648
+ const presenceWriteAuthorized = (req) => tokensEqual(req.headers.get("X-Samograph-Presence-Token"), opts.presenceWriteToken);
1649
+ return Bun.serve({
1650
+ port,
1651
+ hostname: "127.0.0.1",
1652
+ maxRequestBodySize: WEBHOOK_MAX_BYTES,
1653
+ async fetch(req, server) {
1654
+ const url = new URL(req.url);
1655
+ if (req.method === "POST" && url.pathname === "/webhook") {
1656
+ if (!tokensEqual(url.searchParams.get("token"), opts.webhookToken)) {
1657
+ return Response.json({ error: "forbidden" }, { status: 403 });
1658
+ }
1659
+ const contentLength = req.headers.get("content-length");
1660
+ if (contentLength !== null && Number(contentLength) > WEBHOOK_MAX_BYTES) {
1661
+ return Response.json({ error: "payload too large" }, { status: 413 });
1662
+ }
1663
+ let payload = {};
1664
+ try {
1665
+ const body = await req.text();
1666
+ if (new TextEncoder().encode(body).byteLength > WEBHOOK_MAX_BYTES) {
1667
+ return Response.json({ error: "payload too large" }, { status: 413 });
1668
+ }
1669
+ payload = body ? JSON.parse(body) : {};
1670
+ } catch {
1671
+ payload = {};
1672
+ }
1673
+ const transcriptLine = await handleWebhook(payload, transcriptPath);
1674
+ if (transcriptLine !== null) {
1675
+ const activity = activityFromTranscriptLine(transcriptLine);
1676
+ if (activity !== null) {
1677
+ presence = appendPresenceActivity(presence, activity);
1678
+ }
1679
+ }
1680
+ return Response.json({ ok: true });
1681
+ }
1682
+ if (req.method === "GET" && url.pathname === "/health") {
1683
+ return Response.json({
1684
+ ok: true,
1685
+ nonce: url.searchParams.get("nonce") ?? "",
1686
+ marker: HEALTH_MARKER
1687
+ });
1688
+ }
1689
+ if (req.method === "GET" && url.pathname === "/frame") {
1690
+ if (!frameAuthorized(req)) {
1691
+ return new Response("", { status: 403 });
1692
+ }
1693
+ const frame = selectedFrame(latestVideoFrame, framesBySource, url.searchParams.get("source"));
1694
+ if (frame.raw === null) {
1695
+ return new Response("", { status: 404 });
1696
+ }
1697
+ return new Response(frame.raw, {
1698
+ headers: { "Content-Type": "image/png" }
1699
+ });
1700
+ }
1701
+ if (req.method === "GET" && url.pathname === "/frame.json") {
1702
+ if (!frameAuthorized(req)) {
1703
+ return Response.json({ error: "forbidden" }, { status: 403 });
1704
+ }
1705
+ const frame = selectedFrame(latestVideoFrame, framesBySource, url.searchParams.get("source"));
1706
+ if (frame.metadata === null) {
1707
+ return Response.json({ error: "no frame" }, { status: 404 });
1708
+ }
1709
+ return Response.json(frame.metadata);
1710
+ }
1711
+ if (req.method === "GET" && url.pathname === "/frames.json") {
1712
+ if (!frameAuthorized(req)) {
1713
+ return Response.json({ error: "forbidden" }, { status: 403 });
1714
+ }
1715
+ return Response.json({ frames: frameInventory(framesBySource) });
1716
+ }
1717
+ if (req.method === "GET" && url.pathname === "/presence") {
1718
+ if (!presencePageAuthorized(req, url)) {
1719
+ return new Response("", { status: 403 });
1720
+ }
1721
+ return new Response(presencePageHtml(), {
1722
+ headers: {
1723
+ "Content-Type": "text/html; charset=utf-8",
1724
+ "Cache-Control": "no-store"
1725
+ }
1726
+ });
1727
+ }
1728
+ if (req.method === "GET" && url.pathname === "/presence.json") {
1729
+ if (!presenceJsonAuthorized(req)) {
1730
+ return Response.json({ error: "forbidden" }, { status: 403 });
1731
+ }
1732
+ return Response.json(presence, {
1733
+ headers: { "Cache-Control": "no-store" }
1734
+ });
1735
+ }
1736
+ if (req.method === "POST" && url.pathname === "/presence") {
1737
+ if (!presenceWriteAuthorized(req)) {
1738
+ return Response.json({ error: "forbidden" }, { status: 403 });
1739
+ }
1740
+ const contentLength = req.headers.get("content-length");
1741
+ if (contentLength !== null && Number(contentLength) > WEBHOOK_MAX_BYTES) {
1742
+ return Response.json({ error: "payload too large" }, { status: 413 });
1743
+ }
1744
+ let payload = {};
1745
+ try {
1746
+ const body = await req.text();
1747
+ if (new TextEncoder().encode(body).byteLength > WEBHOOK_MAX_BYTES) {
1748
+ return Response.json({ error: "payload too large" }, { status: 413 });
1749
+ }
1750
+ payload = body ? JSON.parse(body) : {};
1751
+ } catch {
1752
+ payload = {};
1753
+ }
1754
+ const rawPayload = payload;
1755
+ const state = normalizePresenceState(rawPayload.state);
1756
+ if (state === null) {
1757
+ return Response.json({ error: "invalid presence state" }, { status: 400 });
1758
+ }
1759
+ const hasMessage = typeof rawPayload.message === "string" && sanitizePresenceText(rawPayload.message) !== "";
1760
+ const message = hasMessage ? sanitizePresenceMessage(rawPayload.message, state) : defaultPresenceMessage(state);
1761
+ if (hasMessage) {
1762
+ presence = appendPresenceActivity(presence, {
1763
+ kind: activityKindForState(state),
1764
+ label: labelForPresenceState(state),
1765
+ text: message
1766
+ });
1767
+ }
1768
+ presence = newPresenceSnapshot(state, message, presence.activities);
1769
+ return Response.json({ ok: true, presence });
1770
+ }
1771
+ if (req.method === "POST" && url.pathname === "/chime") {
1772
+ if (!presenceWriteAuthorized(req)) {
1773
+ return Response.json({ error: "forbidden" }, { status: 403 });
1774
+ }
1775
+ presence = withChime(presence);
1776
+ return Response.json({ ok: true, chime: presence.chime });
1777
+ }
1778
+ if (url.pathname === "/video-ws") {
1779
+ if (!tokensEqual(url.searchParams.get("token"), opts.frameToken)) {
1780
+ return new Response("", { status: 403 });
1781
+ }
1782
+ const upgraded = server.upgrade(req);
1783
+ if (upgraded)
1784
+ return;
1785
+ return new Response("Upgrade Required", { status: 426 });
1786
+ }
1787
+ return new Response("Not Found", { status: 404 });
1788
+ },
1789
+ websocket: {
1790
+ message(_ws, message) {
1791
+ let payload;
1792
+ try {
1793
+ const text = typeof message === "string" ? message : Buffer3.from(message).toString("utf-8");
1794
+ payload = JSON.parse(text);
1795
+ } catch {
1796
+ return;
1797
+ }
1798
+ const decoded = decodeVideoSeparatePng(payload, opts.currentCallId?.() ?? null);
1799
+ if (decoded === null)
1800
+ return;
1801
+ latestVideoFrame.raw = decoded.raw;
1802
+ latestVideoFrame.metadata = decoded.metadata;
1803
+ const frame = { raw: decoded.raw, metadata: decoded.metadata };
1804
+ for (const alias of frameSourceAliases(decoded.metadata)) {
1805
+ framesBySource.set(alias, frame);
1806
+ }
1807
+ }
1808
+ }
1809
+ });
1810
+ }
1811
+
1812
+ // src/tunnel.ts
1813
+ import { spawn as spawnChild } from "child_process";
1814
+ import { mkdirSync as mkdirSync4, openSync as openSync2, closeSync as closeSync2, readFileSync as readFileSync6, writeFileSync as writeFileSync4 } from "fs";
1815
+ import { join as join5 } from "path";
1816
+ var CLOUDFLARED_URL_RE = /https:\/\/[a-z0-9][a-z0-9-]*\.trycloudflare\.com(?![\w.-])/i;
1817
+ function parseCloudflaredUrl(text) {
1818
+ const match = text.match(CLOUDFLARED_URL_RE);
1819
+ return match ? match[0] : null;
1820
+ }
1821
+ function cloudflaredBinary(env = process.env) {
1822
+ return env.CLOUDFLARED_BIN || "cloudflared";
1823
+ }
1824
+ var sleep2 = (ms) => new Promise((r) => setTimeout(r, ms));
1825
+ var CLOUDFLARED_URL_ATTEMPTS = 40;
1826
+ var CLOUDFLARED_URL_SLEEP_MS = 750;
1827
+ async function waitForCloudflaredUrl(readText, sleepFn = sleep2, attempts = CLOUDFLARED_URL_ATTEMPTS, aborted = () => false) {
1828
+ for (let i = 0;i < attempts; i++) {
1829
+ const url = parseCloudflaredUrl(readText());
1830
+ if (url)
1831
+ return url;
1832
+ if (aborted())
1833
+ return null;
1834
+ if (i < attempts - 1)
1835
+ await sleepFn(CLOUDFLARED_URL_SLEEP_MS);
1836
+ }
1837
+ return null;
1838
+ }
1839
+ async function startCloudflared(port) {
1840
+ const dir = samographDir();
1841
+ mkdirSync4(dir, { recursive: true });
1842
+ const logPath = join5(dir, "cloudflared.log");
1843
+ writeFileSync4(logPath, "");
1844
+ const fd = openSync2(logPath, "a");
1845
+ let spawnFailed = false;
1846
+ const child = spawnChild(cloudflaredBinary(), ["tunnel", "--url", `http://127.0.0.1:${port}`], { detached: true, stdio: ["ignore", "ignore", fd] });
1847
+ child.on("error", () => {
1848
+ spawnFailed = true;
1849
+ });
1850
+ child.on("exit", () => {
1851
+ spawnFailed = true;
1852
+ });
1853
+ child.unref();
1854
+ const url = await waitForCloudflaredUrl(() => {
1855
+ try {
1856
+ return readFileSync6(logPath, "utf-8");
1857
+ } catch {
1858
+ return "";
1859
+ }
1860
+ }, sleep2, CLOUDFLARED_URL_ATTEMPTS, () => spawnFailed);
1861
+ closeSync2(fd);
1862
+ if (url === null || typeof child.pid !== "number") {
1863
+ try {
1864
+ child.kill("SIGTERM");
1865
+ } catch {}
1866
+ return null;
1867
+ }
1868
+ return {
1869
+ proc: {
1870
+ get pid() {
1871
+ return child.pid;
1872
+ },
1873
+ kill() {
1874
+ try {
1875
+ child.kill("SIGTERM");
1876
+ } catch {}
1877
+ }
1878
+ },
1879
+ url
1880
+ };
1881
+ }
1882
+
1311
1883
  // src/rtmp.ts
1312
1884
  import {
1313
1885
  existsSync as existsSync5,
1314
- mkdirSync as mkdirSync4,
1886
+ mkdirSync as mkdirSync5,
1315
1887
  copyFileSync as copyFileSync2,
1316
1888
  chmodSync as chmodSync3
1317
1889
  } from "fs";
1318
- import { join as join5 } from "path";
1890
+ import { join as join6 } from "path";
1319
1891
  import { homedir as homedir4 } from "os";
1320
1892
  function rtmpStreamPath(rtmpUrl) {
1321
1893
  const parsed = new URL(rtmpUrl);
1322
1894
  return parsed.pathname.replace(/^\/+/, "");
1323
1895
  }
1324
- var sleep2 = (ms) => new Promise((r) => setTimeout(r, ms));
1896
+ var sleep3 = (ms) => new Promise((r) => setTimeout(r, ms));
1325
1897
  async function ngrokApiPort(fetchFn = fetch) {
1326
1898
  for (const p of [4040, 4041, 4042, 4043]) {
1327
1899
  try {
@@ -1356,7 +1928,7 @@ async function waitForNgrok(port, timeout = 15, fetchFn = fetch) {
1356
1928
  return tunnels[0].public_url;
1357
1929
  }
1358
1930
  } catch {}
1359
- await sleep2(1000);
1931
+ await sleep3(1000);
1360
1932
  }
1361
1933
  return null;
1362
1934
  }
@@ -1400,7 +1972,7 @@ async function ensureMediamtx() {
1400
1972
  if (inPath) {
1401
1973
  return inPath;
1402
1974
  }
1403
- const localBin = join5(homedir4(), ".samograph", "bin", "mediamtx");
1975
+ const localBin = join6(homedir4(), ".samograph", "bin", "mediamtx");
1404
1976
  if (existsSync5(localBin)) {
1405
1977
  return localBin;
1406
1978
  }
@@ -1415,20 +1987,20 @@ async function ensureMediamtx() {
1415
1987
  const url = `https://github.com/bluenviron/mediamtx/releases/download/${version}/${filename}`;
1416
1988
  process.stdout.write(`Downloading mediamtx ${version}...
1417
1989
  `);
1418
- const tmpdir = join5(homedir4(), ".samograph", "tmp-mediamtx");
1419
- mkdirSync4(tmpdir, { recursive: true });
1420
- const archivePath = join5(tmpdir, filename);
1990
+ const tmpdir = join6(homedir4(), ".samograph", "tmp-mediamtx");
1991
+ mkdirSync5(tmpdir, { recursive: true });
1992
+ const archivePath = join6(tmpdir, filename);
1421
1993
  const resp = await fetch(url);
1422
1994
  if (!resp.ok) {
1423
1995
  throw new Error(`Failed to download mediamtx: ${resp.status}`);
1424
1996
  }
1425
1997
  await Bun.write(archivePath, resp);
1426
1998
  await Bun.$`tar -xzf ${archivePath} -C ${tmpdir} mediamtx`.quiet();
1427
- const extracted = join5(tmpdir, "mediamtx");
1999
+ const extracted = join6(tmpdir, "mediamtx");
1428
2000
  if (!existsSync5(extracted)) {
1429
2001
  throw new Error("mediamtx binary not found in downloaded archive");
1430
2002
  }
1431
- mkdirSync4(join5(localBin, ".."), { recursive: true });
2003
+ mkdirSync5(join6(localBin, ".."), { recursive: true });
1432
2004
  copyFileSync2(extracted, localBin);
1433
2005
  chmodSync3(localBin, 493);
1434
2006
  return localBin;
@@ -1439,7 +2011,7 @@ async function startMediamtx() {
1439
2011
  stdout: "ignore",
1440
2012
  stderr: "ignore"
1441
2013
  });
1442
- await sleep2(1500);
2014
+ await sleep3(1500);
1443
2015
  if (proc.exitCode !== null) {
1444
2016
  return null;
1445
2017
  }
@@ -1452,7 +2024,7 @@ function defaultKill(pid, signal) {
1452
2024
  process.kill(pid, signal);
1453
2025
  } catch {}
1454
2026
  }
1455
- function spawnDetached(cmd, opts = {}, spawnFn = spawnChild) {
2027
+ function spawnDetached(cmd, opts = {}, spawnFn = spawnChild2) {
1456
2028
  const [command, ...args] = cmd;
1457
2029
  if (!command) {
1458
2030
  throw new Error("cannot spawn an empty command");
@@ -1476,13 +2048,43 @@ function spawnDetached(cmd, opts = {}, spawnFn = spawnChild) {
1476
2048
  };
1477
2049
  }
1478
2050
  var PRESENCE_PAGE_MARKER = "samograph-presence";
2051
+ var TUNNEL_HEALTH_ATTEMPTS = 40;
2052
+ var TUNNEL_HEALTH_SLEEP_MS = 750;
2053
+ var NGROK_ERROR_HINTS = {
2054
+ ERR_NGROK_727: "account HTTP request limit exceeded"
2055
+ };
2056
+ async function checkTunnelHealth(publicBaseUrl, fetchFn = fetch, sleepFn = sleep4, attempts = TUNNEL_HEALTH_ATTEMPTS, nonceFn = randomUUID2) {
2057
+ for (let i = 0;i < attempts; i++) {
2058
+ const probe = await probeTunnelHealth(publicBaseUrl, fetchFn, nonceFn);
2059
+ if (probe.ok) {
2060
+ return probe;
2061
+ }
2062
+ if (probe.ngrokErrorCode) {
2063
+ return probe;
2064
+ }
2065
+ if (i < attempts - 1) {
2066
+ await sleepFn(TUNNEL_HEALTH_SLEEP_MS);
2067
+ }
2068
+ }
2069
+ return { ok: false, ngrokErrorCode: null };
2070
+ }
2071
+ function tunnelHealthFailureMessage(result) {
2072
+ const options = "Options: --tunnel cloudflared (free, no request limits), " + "--webhook-base with your own tunnel, or upgrade ngrok.";
2073
+ if (result.ngrokErrorCode) {
2074
+ const hint = NGROK_ERROR_HINTS[result.ngrokErrorCode] ?? "see https://ngrok.com/docs/errors";
2075
+ return `Error: tunnel is not relaying requests (ngrok error ${result.ngrokErrorCode}: ${hint}). ` + `The bot would join but receive no transcript. ${options}
2076
+ `;
2077
+ }
2078
+ return "Error: tunnel is not relaying requests (the public /health round-trip never " + "returned this server's response \u2014 interstitial page, unreachable URL, or a " + "tunnel pointed at something else). " + `The bot would join but receive no transcript. ${options}
2079
+ `;
2080
+ }
1479
2081
  var PRESENCE_PREFLIGHT_USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36";
1480
2082
  var PRESENCE_PREFLIGHT_ATTEMPTS = 40;
1481
2083
  var PRESENCE_PREFLIGHT_SLEEP_MS = 750;
1482
- async function sleep3(ms) {
2084
+ async function sleep4(ms) {
1483
2085
  await new Promise((resolve2) => setTimeout(resolve2, ms));
1484
2086
  }
1485
- async function waitForPresenceCamera(url, fetchFn = fetch, sleepFn = sleep3, attempts = PRESENCE_PREFLIGHT_ATTEMPTS) {
2087
+ async function waitForPresenceCamera(url, fetchFn = fetch, sleepFn = sleep4, attempts = PRESENCE_PREFLIGHT_ATTEMPTS) {
1486
2088
  for (let i = 0;i < attempts; i++) {
1487
2089
  try {
1488
2090
  const response = await fetchFn(url, {
@@ -1507,16 +2109,17 @@ async function cmdJoin(args, deps = {}) {
1507
2109
  const kill = deps.kill ?? defaultKill;
1508
2110
  const spawn = deps.spawn ?? spawnDetached;
1509
2111
  const waitForNgrokFn = deps.waitForNgrok ?? waitForNgrok;
2112
+ const startCloudflaredFn = deps.startCloudflared ?? startCloudflared;
1510
2113
  const startMediamtxFn = deps.startMediamtx ?? startMediamtx;
1511
2114
  const startNgrokTcpTunnelFn = deps.startNgrokTcpTunnel ?? startNgrokTcpTunnel;
1512
2115
  const fetchFn = deps.fetch ?? fetch;
1513
- const sleepFn = deps.sleep ?? sleep3;
2116
+ const sleepFn = deps.sleep ?? sleep4;
1514
2117
  const transcriptFile = resolveNewTranscriptFile(args.transcript_dir);
1515
- writeFileSync4(transcriptFile, "", { flag: "wx", mode: 384 });
1516
- const webhookToken = randomUUID();
1517
- const frameToken = randomUUID();
1518
- const presenceToken = randomUUID();
1519
- const presenceWriteToken = randomUUID();
2118
+ writeFileSync5(transcriptFile, "", { flag: "wx", mode: 384 });
2119
+ const webhookToken = randomUUID2();
2120
+ const frameToken = randomUUID2();
2121
+ const presenceToken = randomUUID2();
2122
+ const presenceWriteToken = randomUUID2();
1520
2123
  const keyterms = loadDict(args.dict);
1521
2124
  const name = botName(args.name);
1522
2125
  const port = args.port || 8080;
@@ -1526,7 +2129,7 @@ async function cmdJoin(args, deps = {}) {
1526
2129
  const videoFrameDir = resolveVideoFrameDir(args.frame_dir, false);
1527
2130
  const videoFrameFile = resolveVideoFrameFile(args.frame_dir, false);
1528
2131
  const oldState = loadState();
1529
- for (const pidKey of ["server_pid", "ngrok_pid", "mediamtx_pid"]) {
2132
+ for (const pidKey of ["server_pid", "ngrok_pid", "tunnel_pid", "mediamtx_pid"]) {
1530
2133
  const pid = oldState[pidKey];
1531
2134
  if (typeof pid === "number" && pid) {
1532
2135
  kill(pid, "SIGTERM");
@@ -1534,25 +2137,7 @@ async function cmdJoin(args, deps = {}) {
1534
2137
  }
1535
2138
  const selfPath = fileURLToPath2(import.meta.url);
1536
2139
  const cliPath = selfPath.replace(/commands\/join\.ts$/, "cli.ts");
1537
- const server = spawn([
1538
- process.execPath,
1539
- cliPath,
1540
- "_serve",
1541
- "--port",
1542
- String(port),
1543
- "--transcript-file",
1544
- transcriptFile,
1545
- "--call-id-file",
1546
- stateFile()
1547
- ], {
1548
- env: {
1549
- SAMOGRAPH_WEBHOOK_TOKEN: webhookToken,
1550
- SAMOGRAPH_FRAME_TOKEN: frameToken,
1551
- SAMOGRAPH_PRESENCE_TOKEN: presenceToken,
1552
- SAMOGRAPH_PRESENCE_WRITE_TOKEN: presenceWriteToken
1553
- }
1554
- });
1555
- const started = new Set([server]);
2140
+ const started = new Set;
1556
2141
  let stateSaved = false;
1557
2142
  const cleanupUnsaved = () => {
1558
2143
  if (stateSaved)
@@ -1590,15 +2175,32 @@ async function cmdJoin(args, deps = {}) {
1590
2175
  if (webhookBase !== null) {
1591
2176
  webhookBase = normalizeWebhookBase(webhookBase);
1592
2177
  }
1593
- const ngrok = webhookBase ? null : spawn(["ngrok", "http", String(port), "--log=stdout"]);
2178
+ const useCloudflared = (args.tunnel ?? "ngrok") === "cloudflared";
2179
+ const ngrok = webhookBase || useCloudflared ? null : spawn(["ngrok", "http", String(port), "--log=stdout"]);
1594
2180
  if (ngrok)
1595
2181
  started.add(ngrok);
2182
+ let cloudflared = null;
1596
2183
  try {
1597
2184
  let webhookUrl;
1598
2185
  if (webhookBase) {
1599
2186
  process.stdout.write(`Using external tunnel (--webhook-base): ${webhookBase} \u2192 localhost:${port}
1600
2187
  `);
1601
2188
  webhookUrl = webhookBase;
2189
+ } else if (useCloudflared) {
2190
+ process.stdout.write(`Starting cloudflared tunnel on port ${port}...
2191
+ `);
2192
+ const tunnel = await startCloudflaredFn(port);
2193
+ if (!tunnel) {
2194
+ process.stderr.write("Error: cloudflared tunnel failed to start. Install cloudflared " + "(e.g. brew install cloudflared) or point CLOUDFLARED_BIN at the " + `binary; alternatively use --webhook-base or the default ngrok tunnel.
2195
+ `);
2196
+ cleanupUnsaved();
2197
+ throw new ExitError(1);
2198
+ }
2199
+ cloudflared = tunnel.proc;
2200
+ started.add(cloudflared);
2201
+ webhookUrl = tunnel.url;
2202
+ process.stdout.write(`cloudflared tunnel: ${webhookUrl} \u2192 localhost:${port}
2203
+ `);
1602
2204
  } else {
1603
2205
  process.stdout.write(`Starting ngrok tunnel on port ${port}...
1604
2206
  `);
@@ -1611,6 +2213,33 @@ async function cmdJoin(args, deps = {}) {
1611
2213
  throw new ExitError(1);
1612
2214
  }
1613
2215
  const publicBaseUrl = webhookUrl.replace(/\/+$/, "");
2216
+ const server = spawn([
2217
+ process.execPath,
2218
+ cliPath,
2219
+ "_serve",
2220
+ "--port",
2221
+ String(port),
2222
+ "--transcript-file",
2223
+ transcriptFile,
2224
+ "--call-id-file",
2225
+ stateFile(),
2226
+ "--public-base",
2227
+ publicBaseUrl
2228
+ ], {
2229
+ env: {
2230
+ SAMOGRAPH_WEBHOOK_TOKEN: webhookToken,
2231
+ SAMOGRAPH_FRAME_TOKEN: frameToken,
2232
+ SAMOGRAPH_PRESENCE_TOKEN: presenceToken,
2233
+ SAMOGRAPH_PRESENCE_WRITE_TOKEN: presenceWriteToken
2234
+ }
2235
+ });
2236
+ started.add(server);
2237
+ const health = await checkTunnelHealth(publicBaseUrl, fetchFn, sleepFn);
2238
+ if (!health.ok) {
2239
+ process.stderr.write(tunnelHealthFailureMessage(health));
2240
+ cleanupUnsaved();
2241
+ throw new ExitError(1);
2242
+ }
1614
2243
  const presenceBgSuffix = args.presence_bg ? `&bg=${encodeURIComponent(args.presence_bg)}` : "";
1615
2244
  let presencePageUrl = args.no_presence ? null : `${publicBaseUrl}/presence?token=${encodeURIComponent(presenceToken)}${presenceBgSuffix}`;
1616
2245
  webhookUrl = `${publicBaseUrl}/webhook?token=${encodeURIComponent(webhookToken)}`;
@@ -1788,6 +2417,9 @@ async function cmdJoin(args, deps = {}) {
1788
2417
  newState.presence_token = presenceToken;
1789
2418
  newState.presence_write_token = presenceWriteToken;
1790
2419
  }
2420
+ if (cloudflared) {
2421
+ newState.tunnel_pid = cloudflared.pid;
2422
+ }
1791
2423
  if (mediamtxProc) {
1792
2424
  newState.mediamtx_pid = mediamtxProc.pid;
1793
2425
  }
@@ -1849,6 +2481,10 @@ The agent will appear in the call within ~15 seconds.
1849
2481
  `);
1850
2482
  process.stdout.write(`--------------------------
1851
2483
  `);
2484
+ if (args.intro) {
2485
+ const introText = args.intro_text && args.intro_text.trim() ? args.intro_text.trim() : DEFAULT_INTRO_TEXT;
2486
+ postIntroOnJoin(recall, bid, introText);
2487
+ }
1852
2488
  } catch (err) {
1853
2489
  cleanupUnsaved();
1854
2490
  throw err;
@@ -1856,7 +2492,7 @@ The agent will appear in the call within ~15 seconds.
1856
2492
  }
1857
2493
 
1858
2494
  // src/commands/leave.ts
1859
- import { existsSync as existsSync6, unlinkSync, appendFileSync } from "fs";
2495
+ import { existsSync as existsSync6, unlinkSync, appendFileSync as appendFileSync2 } from "fs";
1860
2496
  function defaultKill2(pid, signal) {
1861
2497
  process.kill(pid, signal);
1862
2498
  }
@@ -1883,12 +2519,12 @@ async function cmdLeave(args, deps = {}) {
1883
2519
  if (existsSync6(transcriptFile)) {
1884
2520
  const ts = fmtSentinelTs(now());
1885
2521
  try {
1886
- appendFileSync(transcriptFile, `[${ts}] SAMOGRAPH_CALL_ENDED
2522
+ appendFileSync2(transcriptFile, `[${ts}] SAMOGRAPH_CALL_ENDED
1887
2523
  `);
1888
2524
  } catch {}
1889
2525
  }
1890
2526
  }
1891
- for (const pidKey of ["server_pid", "ngrok_pid", "mediamtx_pid"]) {
2527
+ for (const pidKey of ["server_pid", "ngrok_pid", "tunnel_pid", "mediamtx_pid"]) {
1892
2528
  const pid = state[pidKey];
1893
2529
  if (typeof pid === "number" && pid) {
1894
2530
  try {
@@ -1908,7 +2544,7 @@ async function cmdLeave(args, deps = {}) {
1908
2544
  }
1909
2545
 
1910
2546
  // src/commands/status.ts
1911
- import { existsSync as existsSync7, readFileSync as readFileSync5, statSync } from "fs";
2547
+ import { existsSync as existsSync7, readFileSync as readFileSync7, statSync } from "fs";
1912
2548
  async function cmdStatus(args, deps = {}) {
1913
2549
  const recall = deps.recall ?? makeRecallClient();
1914
2550
  const fetchFn = deps.fetchFn ?? fetch;
@@ -1926,7 +2562,7 @@ async function cmdStatus(args, deps = {}) {
1926
2562
  const state = loadState();
1927
2563
  const tf = typeof state.transcript_file === "string" ? state.transcript_file : defaultTranscriptFile();
1928
2564
  if (existsSync7(tf)) {
1929
- const lines = readFileSync5(tf, "utf-8").split(/\r?\n/).filter((l) => l.trim() && !SENTINEL_RE.test(l));
2565
+ const lines = readFileSync7(tf, "utf-8").split(/\r?\n/).filter((l) => l.trim() && !SENTINEL_RE.test(l));
1930
2566
  process.stdout.write(`Transcript lines so far: ${lines.length}
1931
2567
  `);
1932
2568
  if (lines.length) {
@@ -2007,7 +2643,7 @@ async function cmdStatus(args, deps = {}) {
2007
2643
 
2008
2644
  // src/commands/screenshot.ts
2009
2645
  import { resolve as resolve2 } from "path";
2010
- var sleep4 = (ms) => new Promise((r) => setTimeout(r, ms));
2646
+ var sleep5 = (ms) => new Promise((r) => setTimeout(r, ms));
2011
2647
  function defaultRun(cmd) {
2012
2648
  const proc = Bun.spawnSync(cmd);
2013
2649
  return { exitCode: proc.exitCode };
@@ -2049,7 +2685,7 @@ async function cmdScreenshot(args, deps = {}) {
2049
2685
  const meetingUrl = state.meeting_url ?? "";
2050
2686
  if (meetingUrl.includes("meet.google.com") || meetingUrl.includes("zoom.us")) {
2051
2687
  focus(meetingUrl);
2052
- await sleep4(1000);
2688
+ await sleep5(1000);
2053
2689
  }
2054
2690
  const result = run(["screencapture", "-x", out]);
2055
2691
  if (result.exitCode !== 0) {
@@ -2117,21 +2753,8 @@ async function cmdTranscript(args, deps = {}) {
2117
2753
  }
2118
2754
  }
2119
2755
 
2120
- // src/commands/chat.ts
2121
- async function cmdChat(args, deps = {}) {
2122
- const recall = deps.recall ?? makeRecallClient();
2123
- const bid = botIdFromArgsOrState(args.bot_id);
2124
- const resp = await recall.sendChat(bid, args.message ?? "");
2125
- if (!resp.ok) {
2126
- const body = await resp.text().catch(() => "");
2127
- throw new Error(`send_chat_message failed: ${resp.status} ${body}`);
2128
- }
2129
- process.stdout.write(`Sent: ${args.message}
2130
- `);
2131
- }
2132
-
2133
2756
  // src/commands/frame.ts
2134
- import { existsSync as existsSync8, readFileSync as readFileSync6, writeFileSync as writeFileSync5 } from "fs";
2757
+ import { existsSync as existsSync8, readFileSync as readFileSync8, writeFileSync as writeFileSync6 } from "fs";
2135
2758
  import { dirname as dirname4, resolve as resolve3 } from "path";
2136
2759
  function defaultRun2(cmd) {
2137
2760
  const proc = Bun.spawnSync(cmd, { timeout: 15000 });
@@ -2229,10 +2852,10 @@ async function cmdFrame(args, deps = {}) {
2229
2852
  if (typeof legacyFrameFile === "string" && legacyFrameFile && existsSync8(legacyFrameFile)) {
2230
2853
  const output = archive && !args.out ? archiveExistingFrame(legacyFrameFile) : out;
2231
2854
  if (!(archive && !args.out)) {
2232
- writeFileSync5(output, readFileSync6(legacyFrameFile));
2855
+ writeFileSync6(output, readFileSync8(legacyFrameFile));
2233
2856
  const metadataFile = frameMetadataPath(legacyFrameFile);
2234
2857
  if (existsSync8(metadataFile)) {
2235
- writeFileSync5(frameMetadataPath(output), readFileSync6(metadataFile));
2858
+ writeFileSync6(frameMetadataPath(output), readFileSync8(metadataFile));
2236
2859
  }
2237
2860
  }
2238
2861
  process.stdout.write(resolve3(output) + `
@@ -2244,7 +2867,7 @@ async function cmdFrame(args, deps = {}) {
2244
2867
  const contentType = resp.headers.get("content-type") ?? "";
2245
2868
  if (resp.status === 200 && contentType.startsWith("image/")) {
2246
2869
  const buf = new Uint8Array(await resp.arrayBuffer());
2247
- writeFileSync5(out, buf);
2870
+ writeFileSync6(out, buf);
2248
2871
  process.stdout.write(resolve3(out) + `
2249
2872
  `);
2250
2873
  return;
@@ -2313,8 +2936,8 @@ async function cmdFrames(deps = {}) {
2313
2936
  }
2314
2937
 
2315
2938
  // src/commands/dicts.ts
2316
- import { existsSync as existsSync9, readdirSync, readFileSync as readFileSync7 } from "fs";
2317
- import { join as join6, basename } from "path";
2939
+ import { existsSync as existsSync9, readdirSync, readFileSync as readFileSync9 } from "fs";
2940
+ import { join as join7, basename } from "path";
2318
2941
  async function cmdDicts() {
2319
2942
  const dir = dictDir();
2320
2943
  if (!existsSync9(dir)) {
@@ -2322,7 +2945,7 @@ async function cmdDicts() {
2322
2945
  `);
2323
2946
  return;
2324
2947
  }
2325
- const files = readdirSync(dir).filter((f) => f.endsWith(".txt")).sort().map((f) => join6(dir, f));
2948
+ const files = readdirSync(dir).filter((f) => f.endsWith(".txt")).sort().map((f) => join7(dir, f));
2326
2949
  if (!files.length) {
2327
2950
  process.stdout.write(`No dictionaries found.
2328
2951
  `);
@@ -2331,7 +2954,7 @@ async function cmdDicts() {
2331
2954
  process.stdout.write(`Available dictionaries:
2332
2955
  `);
2333
2956
  for (const f of files) {
2334
- const terms = readFileSync7(f, "utf-8").split(/\r?\n/).map((l) => l.trim()).filter((l) => l.length > 0);
2957
+ const terms = readFileSync9(f, "utf-8").split(/\r?\n/).map((l) => l.trim()).filter((l) => l.length > 0);
2335
2958
  const stem = basename(f, ".txt");
2336
2959
  process.stdout.write(` ${stem} (${terms.length} terms)
2337
2960
  `);
@@ -2343,228 +2966,25 @@ async function cmdWatch() {
2343
2966
  await watch();
2344
2967
  }
2345
2968
 
2346
- // src/server.ts
2347
- import { appendFileSync as appendFileSync2 } from "fs";
2348
- import { readFileSync as readFileSync8 } from "fs";
2349
- import { Buffer as Buffer3 } from "buffer";
2350
- import { createHash, timingSafeEqual } from "crypto";
2351
- var WEBHOOK_MAX_BYTES = 1024 * 1024;
2352
- function tokensEqual(a, b) {
2353
- if (!a || !b)
2354
- return false;
2355
- const ha = createHash("sha256").update(a).digest();
2356
- const hb = createHash("sha256").update(b).digest();
2357
- return timingSafeEqual(ha, hb);
2358
- }
2359
- async function handleWebhook(payload, transcriptPath) {
2360
- const line = formatTranscriptLine(payload);
2361
- if (line !== null) {
2362
- appendFileSync2(transcriptPath, line + `
2363
- `);
2364
- }
2365
- return line;
2366
- }
2367
- function selectedFrame(latest, bySource, source) {
2368
- const key = normalizeFrameSource(source);
2369
- return key ? bySource.get(key) ?? { raw: null, metadata: null } : latest;
2370
- }
2371
- function frameInventory(bySource) {
2372
- const seen = new Set;
2373
- const frames = [];
2374
- for (const frame of bySource.values()) {
2375
- const sourceKey = frame.metadata?.source_key;
2376
- if (!frame.metadata || !sourceKey || seen.has(sourceKey))
2377
- continue;
2378
- seen.add(sourceKey);
2379
- frames.push(frame.metadata);
2380
- }
2381
- frames.sort((a, b) => String(a.source_key).localeCompare(String(b.source_key)));
2382
- return frames;
2383
- }
2384
- function callIdFromStateFile(path) {
2385
- if (!path)
2386
- return null;
2387
- try {
2388
- const state = JSON.parse(readFileSync8(path, "utf-8"));
2389
- return typeof state.bot_id === "string" ? state.bot_id : null;
2390
- } catch {
2391
- return null;
2392
- }
2393
- }
2394
- function serve(port, transcriptPath, options = {}) {
2395
- const opts = typeof options === "string" || options === null ? { webhookToken: options } : options;
2396
- const latestVideoFrame = { raw: null, metadata: null };
2397
- let presence = newPresenceSnapshot();
2398
- const framesBySource = new Map;
2399
- const frameAuthorized = (req) => tokensEqual(req.headers.get("X-Samograph-Frame-Token"), opts.frameToken);
2400
- const presencePageAuthorized = (req, url) => tokensEqual(req.headers.get("X-Samograph-Presence-Token"), opts.presenceToken) || tokensEqual(url.searchParams.get("token"), opts.presenceToken);
2401
- const presenceJsonAuthorized = (req) => tokensEqual(req.headers.get("X-Samograph-Presence-Token"), opts.presenceToken);
2402
- const presenceWriteAuthorized = (req) => tokensEqual(req.headers.get("X-Samograph-Presence-Token"), opts.presenceWriteToken);
2403
- return Bun.serve({
2404
- port,
2405
- hostname: "127.0.0.1",
2406
- maxRequestBodySize: WEBHOOK_MAX_BYTES,
2407
- async fetch(req, server) {
2408
- const url = new URL(req.url);
2409
- if (req.method === "POST" && url.pathname === "/webhook") {
2410
- if (!tokensEqual(url.searchParams.get("token"), opts.webhookToken)) {
2411
- return Response.json({ error: "forbidden" }, { status: 403 });
2412
- }
2413
- const contentLength = req.headers.get("content-length");
2414
- if (contentLength !== null && Number(contentLength) > WEBHOOK_MAX_BYTES) {
2415
- return Response.json({ error: "payload too large" }, { status: 413 });
2416
- }
2417
- let payload = {};
2418
- try {
2419
- const body = await req.text();
2420
- if (new TextEncoder().encode(body).byteLength > WEBHOOK_MAX_BYTES) {
2421
- return Response.json({ error: "payload too large" }, { status: 413 });
2422
- }
2423
- payload = body ? JSON.parse(body) : {};
2424
- } catch {
2425
- payload = {};
2426
- }
2427
- const transcriptLine = await handleWebhook(payload, transcriptPath);
2428
- if (transcriptLine !== null) {
2429
- const activity = activityFromTranscriptLine(transcriptLine);
2430
- if (activity !== null) {
2431
- presence = appendPresenceActivity(presence, activity);
2432
- }
2433
- }
2434
- return Response.json({ ok: true });
2435
- }
2436
- if (req.method === "GET" && url.pathname === "/frame") {
2437
- if (!frameAuthorized(req)) {
2438
- return new Response("", { status: 403 });
2439
- }
2440
- const frame = selectedFrame(latestVideoFrame, framesBySource, url.searchParams.get("source"));
2441
- if (frame.raw === null) {
2442
- return new Response("", { status: 404 });
2443
- }
2444
- return new Response(frame.raw, {
2445
- headers: { "Content-Type": "image/png" }
2446
- });
2447
- }
2448
- if (req.method === "GET" && url.pathname === "/frame.json") {
2449
- if (!frameAuthorized(req)) {
2450
- return Response.json({ error: "forbidden" }, { status: 403 });
2451
- }
2452
- const frame = selectedFrame(latestVideoFrame, framesBySource, url.searchParams.get("source"));
2453
- if (frame.metadata === null) {
2454
- return Response.json({ error: "no frame" }, { status: 404 });
2455
- }
2456
- return Response.json(frame.metadata);
2457
- }
2458
- if (req.method === "GET" && url.pathname === "/frames.json") {
2459
- if (!frameAuthorized(req)) {
2460
- return Response.json({ error: "forbidden" }, { status: 403 });
2461
- }
2462
- return Response.json({ frames: frameInventory(framesBySource) });
2463
- }
2464
- if (req.method === "GET" && url.pathname === "/presence") {
2465
- if (!presencePageAuthorized(req, url)) {
2466
- return new Response("", { status: 403 });
2467
- }
2468
- return new Response(presencePageHtml(), {
2469
- headers: {
2470
- "Content-Type": "text/html; charset=utf-8",
2471
- "Cache-Control": "no-store"
2472
- }
2473
- });
2474
- }
2475
- if (req.method === "GET" && url.pathname === "/presence.json") {
2476
- if (!presenceJsonAuthorized(req)) {
2477
- return Response.json({ error: "forbidden" }, { status: 403 });
2478
- }
2479
- return Response.json(presence, {
2480
- headers: { "Cache-Control": "no-store" }
2481
- });
2482
- }
2483
- if (req.method === "POST" && url.pathname === "/presence") {
2484
- if (!presenceWriteAuthorized(req)) {
2485
- return Response.json({ error: "forbidden" }, { status: 403 });
2486
- }
2487
- const contentLength = req.headers.get("content-length");
2488
- if (contentLength !== null && Number(contentLength) > WEBHOOK_MAX_BYTES) {
2489
- return Response.json({ error: "payload too large" }, { status: 413 });
2490
- }
2491
- let payload = {};
2492
- try {
2493
- const body = await req.text();
2494
- if (new TextEncoder().encode(body).byteLength > WEBHOOK_MAX_BYTES) {
2495
- return Response.json({ error: "payload too large" }, { status: 413 });
2496
- }
2497
- payload = body ? JSON.parse(body) : {};
2498
- } catch {
2499
- payload = {};
2500
- }
2501
- const rawPayload = payload;
2502
- const state = normalizePresenceState(rawPayload.state);
2503
- if (state === null) {
2504
- return Response.json({ error: "invalid presence state" }, { status: 400 });
2505
- }
2506
- const hasMessage = typeof rawPayload.message === "string" && sanitizePresenceText(rawPayload.message) !== "";
2507
- const message = hasMessage ? sanitizePresenceMessage(rawPayload.message, state) : defaultPresenceMessage(state);
2508
- if (hasMessage) {
2509
- presence = appendPresenceActivity(presence, {
2510
- kind: activityKindForState(state),
2511
- label: labelForPresenceState(state),
2512
- text: message
2513
- });
2514
- }
2515
- presence = newPresenceSnapshot(state, message, presence.activities);
2516
- return Response.json({ ok: true, presence });
2517
- }
2518
- if (url.pathname === "/video-ws") {
2519
- if (!tokensEqual(url.searchParams.get("token"), opts.frameToken)) {
2520
- return new Response("", { status: 403 });
2521
- }
2522
- const upgraded = server.upgrade(req);
2523
- if (upgraded)
2524
- return;
2525
- return new Response("Upgrade Required", { status: 426 });
2526
- }
2527
- return new Response("Not Found", { status: 404 });
2528
- },
2529
- websocket: {
2530
- message(_ws, message) {
2531
- let payload;
2532
- try {
2533
- const text = typeof message === "string" ? message : Buffer3.from(message).toString("utf-8");
2534
- payload = JSON.parse(text);
2535
- } catch {
2536
- return;
2537
- }
2538
- const decoded = decodeVideoSeparatePng(payload, opts.currentCallId?.() ?? null);
2539
- if (decoded === null)
2540
- return;
2541
- latestVideoFrame.raw = decoded.raw;
2542
- latestVideoFrame.metadata = decoded.metadata;
2543
- const frame = { raw: decoded.raw, metadata: decoded.metadata };
2544
- for (const alias of frameSourceAliases(decoded.metadata)) {
2545
- framesBySource.set(alias, frame);
2546
- }
2547
- }
2548
- }
2549
- });
2550
- }
2551
-
2552
2969
  // src/commands/serve.ts
2553
2970
  function resolveServeOptions(args, env = process.env) {
2554
2971
  return {
2555
2972
  webhookToken: args.webhook_token || env.SAMOGRAPH_WEBHOOK_TOKEN || "",
2556
2973
  frameToken: args.frame_token || env.SAMOGRAPH_FRAME_TOKEN || "",
2557
2974
  presenceToken: args.presence_token || env.SAMOGRAPH_PRESENCE_TOKEN || "",
2558
- presenceWriteToken: args.presence_write_token || env.SAMOGRAPH_PRESENCE_WRITE_TOKEN || ""
2975
+ presenceWriteToken: args.presence_write_token || env.SAMOGRAPH_PRESENCE_WRITE_TOKEN || "",
2976
+ publicBase: args.public_base || env.SAMOGRAPH_PUBLIC_BASE || ""
2559
2977
  };
2560
2978
  }
2561
2979
  async function cmdServe(args) {
2562
2980
  const port = args.port || 8080;
2563
2981
  const transcriptPath = args.transcript_file;
2982
+ const { publicBase, ...tokens } = resolveServeOptions(args);
2564
2983
  serve(port, transcriptPath, {
2565
- ...resolveServeOptions(args),
2984
+ ...tokens,
2566
2985
  currentCallId: () => callIdFromStateFile(args.call_id_file)
2567
2986
  });
2987
+ startTunnelWatchdog({ publicBase, transcriptPath });
2568
2988
  await new Promise(() => {});
2569
2989
  }
2570
2990
 
@@ -2636,7 +3056,7 @@ async function cmdDoctor() {
2636
3056
  }
2637
3057
 
2638
3058
  // src/googleDocs.ts
2639
- import { existsSync as existsSync11, readFileSync as readFileSync9 } from "fs";
3059
+ import { existsSync as existsSync11, readFileSync as readFileSync10 } from "fs";
2640
3060
  import { createSign } from "crypto";
2641
3061
  var DOCS_SCOPE = "https://www.googleapis.com/auth/documents";
2642
3062
  var TOKEN_URI = "https://oauth2.googleapis.com/token";
@@ -2664,7 +3084,7 @@ function loadServiceAccountCredentials(path) {
2664
3084
  if (!existsSync11(rawPath)) {
2665
3085
  throw new Error(`Google credentials file not found: ${rawPath}`);
2666
3086
  }
2667
- const parsed = JSON.parse(readFileSync9(rawPath, "utf-8"));
3087
+ const parsed = JSON.parse(readFileSync10(rawPath, "utf-8"));
2668
3088
  if (!parsed.client_email || !parsed.private_key) {
2669
3089
  throw new Error("Google credentials file must contain client_email and private_key");
2670
3090
  }
@@ -2950,14 +3370,16 @@ Put your AI agent in Zoom and Google Meet calls.
2950
3370
  samograph joins through Recall.ai, streams live transcript lines,
2951
3371
  captures call frames on demand, and sends explicit chat messages.
2952
3372
 
2953
- Requires: Bun, RECALL_API_KEY env var (get one at recall.ai), and ngrok (or an alternative tunnel via --webhook-base).
3373
+ Requires: Bun, RECALL_API_KEY env var (get one at recall.ai), and a tunnel:
3374
+ ngrok (default), cloudflared (--tunnel cloudflared), or your own via --webhook-base.
2954
3375
 
2955
3376
  commands:
2956
- join <url> [--name N] [--dict D] [--port P] [--transcript-dir DIR] [--rtmp-url URL] [--rtmp] [--no-ws-video] [--frame-dir DIR] [--webhook-base URL] [--variant web|web_4_core|web_gpu] [--no-presence] [--presence-bg MODE]
3377
+ join <url> [--name N] [--dict D] [--port P] [--transcript-dir DIR] [--rtmp-url URL] [--rtmp] [--no-ws-video] [--frame-dir DIR] [--tunnel ngrok|cloudflared] [--webhook-base URL] [--variant web|web_4_core|web_gpu] [--no-presence] [--presence-bg MODE] [--intro] [--intro-text TEXT]
2957
3378
  leave [bot_id]
2958
3379
  status [bot_id]
2959
3380
  screenshot [--out FILE] [bot_id]
2960
3381
  chat <message> [--bot-id ID]
3382
+ intro [--intro-text TEXT] [--context] [--bot-id ID]
2961
3383
  presence <listening|thinking|speaking|acting|idle> [message]
2962
3384
  transcript [--local] [--file FILE] [--cursor N] [--limit N] [bot_id]
2963
3385
  dicts
@@ -2984,8 +3406,13 @@ options:
2984
3406
  --transcript-dir DIR Directory for timestamped transcript files
2985
3407
  --frame-dir DIR Directory for on-demand frame output
2986
3408
  --no-ws-video Disable WebSocket call-frame capture
2987
- --webhook-base URL Use an existing public tunnel URL instead of starting ngrok
2988
- (e.g. localtunnel/cloudflared pointing at --port)
3409
+ --tunnel NAME Tunnel to start for webhooks: ngrok|cloudflared
3410
+ (default: ngrok; cloudflared quick tunnels are free
3411
+ with no request limits \u2014 recommended when ngrok hits
3412
+ ERR_NGROK_727). Binary from PATH or CLOUDFLARED_BIN.
3413
+ --webhook-base URL Use an existing public tunnel URL instead of starting one
3414
+ (e.g. localtunnel/cloudflared pointing at --port);
3415
+ mutually exclusive with --tunnel
2989
3416
  --variant NAME Recall Output Media bot size: web|web_4_core|web_gpu
2990
3417
  (default: web_4_core; smoothest camera rendering)
2991
3418
  --no-presence Join without the presence camera page (skips the
@@ -2994,6 +3421,11 @@ options:
2994
3421
  (default: robot \u2014 static samoagent avatar image)
2995
3422
  --rtmp Use local RTMP path through ngrok TCP
2996
3423
  --rtmp-url URL Use an existing RTMP endpoint
3424
+ --intro After joining, post a short self-introduction into the
3425
+ meeting chat once the bot is admitted (best-effort).
3426
+ Default text is English (no transcript yet to detect
3427
+ the call language).
3428
+ --intro-text TEXT Custom introduction text for --intro (overrides default)
2997
3429
 
2998
3430
  examples:
2999
3431
  samograph join "https://meet.google.com/abc-defg-hij" --name Leo
@@ -3092,7 +3524,8 @@ function parseArgs(argv) {
3092
3524
  const positionals = [];
3093
3525
  const opts = {};
3094
3526
  const valueFlags = {
3095
- join: new Set(["--name", "--dict", "--port", "--transcript-dir", "--rtmp-url", "--frame-dir", "--webhook-base", "--variant", "--presence-bg"]),
3527
+ join: new Set(["--name", "--dict", "--port", "--transcript-dir", "--rtmp-url", "--frame-dir", "--webhook-base", "--tunnel", "--variant", "--presence-bg", "--intro-text"]),
3528
+ intro: new Set(["--bot-id", "--intro-text"]),
3096
3529
  leave: new Set,
3097
3530
  status: new Set,
3098
3531
  screenshot: new Set(["--out"]),
@@ -3105,10 +3538,11 @@ function parseArgs(argv) {
3105
3538
  frame: new Set(["--out", "--source"]),
3106
3539
  frames: new Set,
3107
3540
  doctor: new Set,
3108
- _serve: new Set(["--port", "--transcript-file", "--webhook-token", "--call-id-file", "--frame-token", "--presence-token", "--presence-write-token"])
3541
+ _serve: new Set(["--port", "--transcript-file", "--webhook-token", "--call-id-file", "--frame-token", "--presence-token", "--presence-write-token", "--public-base"])
3109
3542
  };
3110
3543
  const boolFlags = {
3111
- join: new Set(["--rtmp", "--no-ws-video", "--no-presence"]),
3544
+ join: new Set(["--rtmp", "--no-ws-video", "--no-presence", "--intro"]),
3545
+ intro: new Set(["--context"]),
3112
3546
  leave: new Set,
3113
3547
  status: new Set,
3114
3548
  screenshot: new Set,
@@ -3185,11 +3619,20 @@ function parseArgs(argv) {
3185
3619
  result.rtmp = opts["--rtmp"] === true;
3186
3620
  result.ws_video = opts["--no-ws-video"] !== true;
3187
3621
  result.webhook_base = opts["--webhook-base"] ?? null;
3622
+ result.tunnel = opts["--tunnel"] ?? "ngrok";
3623
+ if (!["ngrok", "cloudflared"].includes(result.tunnel)) {
3624
+ throw new ArgError(`argument --tunnel: invalid choice: '${result.tunnel}' (choose from ngrok, cloudflared)`);
3625
+ }
3626
+ if (opts["--tunnel"] !== undefined && result.webhook_base !== null) {
3627
+ throw new ArgError("argument --tunnel: not allowed with --webhook-base (an external tunnel is already provided)");
3628
+ }
3188
3629
  result.no_presence = opts["--no-presence"] === true;
3189
3630
  result.presence_bg = opts["--presence-bg"] ?? null;
3190
3631
  if (result.presence_bg !== null && !["robot", "sphere", "field", "static", "cycle"].includes(result.presence_bg)) {
3191
3632
  throw new ArgError(`argument --presence-bg: invalid choice: '${result.presence_bg}' (choose from robot, sphere, field, static, cycle)`);
3192
3633
  }
3634
+ result.intro = opts["--intro"] === true;
3635
+ result.intro_text = opts["--intro-text"] ?? null;
3193
3636
  result.frame_dir = opts["--frame-dir"] ?? null;
3194
3637
  result.variant = opts["--variant"] ?? "web_4_core";
3195
3638
  if (result.variant !== null && !["web", "web_4_core", "web_gpu"].includes(result.variant)) {
@@ -3241,6 +3684,12 @@ function parseArgs(argv) {
3241
3684
  result.bot_id = opts["--bot-id"] ?? null;
3242
3685
  break;
3243
3686
  }
3687
+ case "intro": {
3688
+ result.bot_id = opts["--bot-id"] ?? null;
3689
+ result.intro_text = opts["--intro-text"] ?? null;
3690
+ result.context = opts["--context"] === true;
3691
+ break;
3692
+ }
3244
3693
  case "presence": {
3245
3694
  if (positionals.length < 1) {
3246
3695
  throw new ArgError("the following arguments are required: state");
@@ -3287,6 +3736,7 @@ function parseArgs(argv) {
3287
3736
  result.transcript_file = opts["--transcript-file"];
3288
3737
  result.webhook_token = opts["--webhook-token"] ?? "";
3289
3738
  result.call_id_file = opts["--call-id-file"] ?? "";
3739
+ result.public_base = opts["--public-base"] ?? "";
3290
3740
  result.frame_token = opts["--frame-token"] ?? "";
3291
3741
  result.presence_token = opts["--presence-token"] ?? "";
3292
3742
  result.presence_write_token = opts["--presence-write-token"] ?? "";
@@ -3309,6 +3759,8 @@ async function dispatch(args) {
3309
3759
  return cmdTranscript(args);
3310
3760
  case "chat":
3311
3761
  return cmdChat(args);
3762
+ case "intro":
3763
+ return cmdIntro(args);
3312
3764
  case "presence":
3313
3765
  return cmdPresence(args);
3314
3766
  case "frame":