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.
- package/README.md +21 -5
- package/dist/cli.js +746 -294
- 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.
|
|
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
|
|
805
|
-
import { spawn as
|
|
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
|
|
1886
|
+
mkdirSync as mkdirSync5,
|
|
1315
1887
|
copyFileSync as copyFileSync2,
|
|
1316
1888
|
chmodSync as chmodSync3
|
|
1317
1889
|
} from "fs";
|
|
1318
|
-
import { join as
|
|
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
|
|
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
|
|
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 =
|
|
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 =
|
|
1419
|
-
|
|
1420
|
-
const archivePath =
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
|
2084
|
+
async function sleep4(ms) {
|
|
1483
2085
|
await new Promise((resolve2) => setTimeout(resolve2, ms));
|
|
1484
2086
|
}
|
|
1485
|
-
async function waitForPresenceCamera(url, fetchFn = fetch, sleepFn =
|
|
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 ??
|
|
2116
|
+
const sleepFn = deps.sleep ?? sleep4;
|
|
1514
2117
|
const transcriptFile = resolveNewTranscriptFile(args.transcript_dir);
|
|
1515
|
-
|
|
1516
|
-
const webhookToken =
|
|
1517
|
-
const frameToken =
|
|
1518
|
-
const presenceToken =
|
|
1519
|
-
const presenceWriteToken =
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
2855
|
+
writeFileSync6(output, readFileSync8(legacyFrameFile));
|
|
2233
2856
|
const metadataFile = frameMetadataPath(legacyFrameFile);
|
|
2234
2857
|
if (existsSync8(metadataFile)) {
|
|
2235
|
-
|
|
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
|
-
|
|
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
|
|
2317
|
-
import { join as
|
|
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) =>
|
|
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 =
|
|
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
|
-
...
|
|
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
|
|
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(
|
|
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
|
|
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
|
-
--
|
|
2988
|
-
(
|
|
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":
|