samograph 0.6.1 → 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 +761 -295
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -5,15 +5,29 @@ var __getProtoOf = Object.getPrototypeOf;
|
|
|
5
5
|
var __defProp = Object.defineProperty;
|
|
6
6
|
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
7
7
|
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
|
+
function __accessProp(key) {
|
|
9
|
+
return this[key];
|
|
10
|
+
}
|
|
11
|
+
var __toESMCache_node;
|
|
12
|
+
var __toESMCache_esm;
|
|
8
13
|
var __toESM = (mod, isNodeMode, target) => {
|
|
14
|
+
var canCache = mod != null && typeof mod === "object";
|
|
15
|
+
if (canCache) {
|
|
16
|
+
var cache = isNodeMode ? __toESMCache_node ??= new WeakMap : __toESMCache_esm ??= new WeakMap;
|
|
17
|
+
var cached = cache.get(mod);
|
|
18
|
+
if (cached)
|
|
19
|
+
return cached;
|
|
20
|
+
}
|
|
9
21
|
target = mod != null ? __create(__getProtoOf(mod)) : {};
|
|
10
22
|
const to = isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target;
|
|
11
23
|
for (let key of __getOwnPropNames(mod))
|
|
12
24
|
if (!__hasOwnProp.call(to, key))
|
|
13
25
|
__defProp(to, key, {
|
|
14
|
-
get: (
|
|
26
|
+
get: __accessProp.bind(mod, key),
|
|
15
27
|
enumerable: true
|
|
16
28
|
});
|
|
29
|
+
if (canCache)
|
|
30
|
+
cache.set(mod, to);
|
|
17
31
|
return to;
|
|
18
32
|
};
|
|
19
33
|
var __commonJS = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports, mod), mod.exports);
|
|
@@ -22,7 +36,7 @@ var __commonJS = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports,
|
|
|
22
36
|
var require_package = __commonJS((exports, module) => {
|
|
23
37
|
module.exports = {
|
|
24
38
|
name: "samograph",
|
|
25
|
-
version: "0.
|
|
39
|
+
version: "0.7.0",
|
|
26
40
|
description: "Let AI agents join Zoom and Google Meet calls as active participants.",
|
|
27
41
|
type: "module",
|
|
28
42
|
license: "Apache-2.0",
|
|
@@ -160,7 +174,15 @@ function newPresenceSnapshot(state = "listening", message = defaultPresenceMessa
|
|
|
160
174
|
state,
|
|
161
175
|
message,
|
|
162
176
|
updated_at: new Date().toISOString(),
|
|
163
|
-
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() }
|
|
164
186
|
};
|
|
165
187
|
}
|
|
166
188
|
var ACTIVITY_LIMIT = 16;
|
|
@@ -410,6 +432,9 @@ function presencePageHtml() {
|
|
|
410
432
|
overflow: hidden;
|
|
411
433
|
display: flex;
|
|
412
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. */
|
|
413
438
|
justify-content: flex-start;
|
|
414
439
|
}
|
|
415
440
|
.item {
|
|
@@ -695,6 +720,54 @@ function presencePageHtml() {
|
|
|
695
720
|
resize();
|
|
696
721
|
requestAnimationFrame(draw);
|
|
697
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
|
+
}
|
|
698
771
|
function formatUpdated(value) {
|
|
699
772
|
const date = new Date(String(value || ""));
|
|
700
773
|
if (Number.isNaN(date.getTime())) return "Waiting for live signal";
|
|
@@ -726,8 +799,10 @@ function presencePageHtml() {
|
|
|
726
799
|
element.append(empty);
|
|
727
800
|
return;
|
|
728
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.
|
|
729
804
|
let lastLabel = "";
|
|
730
|
-
for (const item of items.slice(0, 14)) {
|
|
805
|
+
for (const item of items.slice(0, 14).reverse()) {
|
|
731
806
|
const row = document.createElement("div");
|
|
732
807
|
row.className = "item";
|
|
733
808
|
const label = document.createElement("div");
|
|
@@ -743,6 +818,12 @@ function presencePageHtml() {
|
|
|
743
818
|
element.append(row);
|
|
744
819
|
}
|
|
745
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();
|
|
746
827
|
async function refresh() {
|
|
747
828
|
try {
|
|
748
829
|
const response = await fetch("/presence.json", {
|
|
@@ -751,6 +832,12 @@ function presencePageHtml() {
|
|
|
751
832
|
});
|
|
752
833
|
if (!response.ok) return;
|
|
753
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
|
+
}
|
|
754
841
|
const state = String(data.state || "listening");
|
|
755
842
|
document.getElementById("live").textContent = state;
|
|
756
843
|
const buckets = { heard: [], comment: [] };
|
|
@@ -773,23 +860,33 @@ function presencePageHtml() {
|
|
|
773
860
|
document.documentElement.style.setProperty("--accent-mid", pair[2]);
|
|
774
861
|
} catch {}
|
|
775
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
|
+
}
|
|
776
871
|
if (backgroundMode === "robot") {
|
|
777
872
|
initRobot();
|
|
778
873
|
} else {
|
|
779
874
|
initPlasma();
|
|
780
875
|
initFpsProbe();
|
|
781
|
-
refresh();
|
|
782
|
-
setInterval(refresh, 1000);
|
|
783
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();
|
|
784
881
|
</script>
|
|
785
882
|
</body>
|
|
786
883
|
</html>`;
|
|
787
884
|
}
|
|
788
885
|
|
|
789
886
|
// src/commands/join.ts
|
|
790
|
-
import { writeFileSync as
|
|
791
|
-
import { spawn as
|
|
792
|
-
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";
|
|
793
890
|
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
794
891
|
|
|
795
892
|
// src/transcript.ts
|
|
@@ -1270,6 +1367,14 @@ function makeRecallClient(fetchFn = fetch) {
|
|
|
1270
1367
|
signal: AbortSignal.timeout(1e4)
|
|
1271
1368
|
});
|
|
1272
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
|
+
},
|
|
1273
1378
|
async screenshot(botId) {
|
|
1274
1379
|
return fetchFn(`${RECALL_BASE}/bot/${botId}/screenshot/`, {
|
|
1275
1380
|
method: "GET",
|
|
@@ -1294,20 +1399,501 @@ function makeRecallClient(fetchFn = fetch) {
|
|
|
1294
1399
|
};
|
|
1295
1400
|
}
|
|
1296
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
|
+
|
|
1297
1883
|
// src/rtmp.ts
|
|
1298
1884
|
import {
|
|
1299
1885
|
existsSync as existsSync5,
|
|
1300
|
-
mkdirSync as
|
|
1886
|
+
mkdirSync as mkdirSync5,
|
|
1301
1887
|
copyFileSync as copyFileSync2,
|
|
1302
1888
|
chmodSync as chmodSync3
|
|
1303
1889
|
} from "fs";
|
|
1304
|
-
import { join as
|
|
1890
|
+
import { join as join6 } from "path";
|
|
1305
1891
|
import { homedir as homedir4 } from "os";
|
|
1306
1892
|
function rtmpStreamPath(rtmpUrl) {
|
|
1307
1893
|
const parsed = new URL(rtmpUrl);
|
|
1308
1894
|
return parsed.pathname.replace(/^\/+/, "");
|
|
1309
1895
|
}
|
|
1310
|
-
var
|
|
1896
|
+
var sleep3 = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
1311
1897
|
async function ngrokApiPort(fetchFn = fetch) {
|
|
1312
1898
|
for (const p of [4040, 4041, 4042, 4043]) {
|
|
1313
1899
|
try {
|
|
@@ -1342,7 +1928,7 @@ async function waitForNgrok(port, timeout = 15, fetchFn = fetch) {
|
|
|
1342
1928
|
return tunnels[0].public_url;
|
|
1343
1929
|
}
|
|
1344
1930
|
} catch {}
|
|
1345
|
-
await
|
|
1931
|
+
await sleep3(1000);
|
|
1346
1932
|
}
|
|
1347
1933
|
return null;
|
|
1348
1934
|
}
|
|
@@ -1386,7 +1972,7 @@ async function ensureMediamtx() {
|
|
|
1386
1972
|
if (inPath) {
|
|
1387
1973
|
return inPath;
|
|
1388
1974
|
}
|
|
1389
|
-
const localBin =
|
|
1975
|
+
const localBin = join6(homedir4(), ".samograph", "bin", "mediamtx");
|
|
1390
1976
|
if (existsSync5(localBin)) {
|
|
1391
1977
|
return localBin;
|
|
1392
1978
|
}
|
|
@@ -1401,20 +1987,20 @@ async function ensureMediamtx() {
|
|
|
1401
1987
|
const url = `https://github.com/bluenviron/mediamtx/releases/download/${version}/${filename}`;
|
|
1402
1988
|
process.stdout.write(`Downloading mediamtx ${version}...
|
|
1403
1989
|
`);
|
|
1404
|
-
const tmpdir =
|
|
1405
|
-
|
|
1406
|
-
const archivePath =
|
|
1990
|
+
const tmpdir = join6(homedir4(), ".samograph", "tmp-mediamtx");
|
|
1991
|
+
mkdirSync5(tmpdir, { recursive: true });
|
|
1992
|
+
const archivePath = join6(tmpdir, filename);
|
|
1407
1993
|
const resp = await fetch(url);
|
|
1408
1994
|
if (!resp.ok) {
|
|
1409
1995
|
throw new Error(`Failed to download mediamtx: ${resp.status}`);
|
|
1410
1996
|
}
|
|
1411
1997
|
await Bun.write(archivePath, resp);
|
|
1412
1998
|
await Bun.$`tar -xzf ${archivePath} -C ${tmpdir} mediamtx`.quiet();
|
|
1413
|
-
const extracted =
|
|
1999
|
+
const extracted = join6(tmpdir, "mediamtx");
|
|
1414
2000
|
if (!existsSync5(extracted)) {
|
|
1415
2001
|
throw new Error("mediamtx binary not found in downloaded archive");
|
|
1416
2002
|
}
|
|
1417
|
-
|
|
2003
|
+
mkdirSync5(join6(localBin, ".."), { recursive: true });
|
|
1418
2004
|
copyFileSync2(extracted, localBin);
|
|
1419
2005
|
chmodSync3(localBin, 493);
|
|
1420
2006
|
return localBin;
|
|
@@ -1425,7 +2011,7 @@ async function startMediamtx() {
|
|
|
1425
2011
|
stdout: "ignore",
|
|
1426
2012
|
stderr: "ignore"
|
|
1427
2013
|
});
|
|
1428
|
-
await
|
|
2014
|
+
await sleep3(1500);
|
|
1429
2015
|
if (proc.exitCode !== null) {
|
|
1430
2016
|
return null;
|
|
1431
2017
|
}
|
|
@@ -1438,7 +2024,7 @@ function defaultKill(pid, signal) {
|
|
|
1438
2024
|
process.kill(pid, signal);
|
|
1439
2025
|
} catch {}
|
|
1440
2026
|
}
|
|
1441
|
-
function spawnDetached(cmd, opts = {}, spawnFn =
|
|
2027
|
+
function spawnDetached(cmd, opts = {}, spawnFn = spawnChild2) {
|
|
1442
2028
|
const [command, ...args] = cmd;
|
|
1443
2029
|
if (!command) {
|
|
1444
2030
|
throw new Error("cannot spawn an empty command");
|
|
@@ -1462,13 +2048,43 @@ function spawnDetached(cmd, opts = {}, spawnFn = spawnChild) {
|
|
|
1462
2048
|
};
|
|
1463
2049
|
}
|
|
1464
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
|
+
}
|
|
1465
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";
|
|
1466
2082
|
var PRESENCE_PREFLIGHT_ATTEMPTS = 40;
|
|
1467
2083
|
var PRESENCE_PREFLIGHT_SLEEP_MS = 750;
|
|
1468
|
-
async function
|
|
2084
|
+
async function sleep4(ms) {
|
|
1469
2085
|
await new Promise((resolve2) => setTimeout(resolve2, ms));
|
|
1470
2086
|
}
|
|
1471
|
-
async function waitForPresenceCamera(url, fetchFn = fetch, sleepFn =
|
|
2087
|
+
async function waitForPresenceCamera(url, fetchFn = fetch, sleepFn = sleep4, attempts = PRESENCE_PREFLIGHT_ATTEMPTS) {
|
|
1472
2088
|
for (let i = 0;i < attempts; i++) {
|
|
1473
2089
|
try {
|
|
1474
2090
|
const response = await fetchFn(url, {
|
|
@@ -1493,16 +2109,17 @@ async function cmdJoin(args, deps = {}) {
|
|
|
1493
2109
|
const kill = deps.kill ?? defaultKill;
|
|
1494
2110
|
const spawn = deps.spawn ?? spawnDetached;
|
|
1495
2111
|
const waitForNgrokFn = deps.waitForNgrok ?? waitForNgrok;
|
|
2112
|
+
const startCloudflaredFn = deps.startCloudflared ?? startCloudflared;
|
|
1496
2113
|
const startMediamtxFn = deps.startMediamtx ?? startMediamtx;
|
|
1497
2114
|
const startNgrokTcpTunnelFn = deps.startNgrokTcpTunnel ?? startNgrokTcpTunnel;
|
|
1498
2115
|
const fetchFn = deps.fetch ?? fetch;
|
|
1499
|
-
const sleepFn = deps.sleep ??
|
|
2116
|
+
const sleepFn = deps.sleep ?? sleep4;
|
|
1500
2117
|
const transcriptFile = resolveNewTranscriptFile(args.transcript_dir);
|
|
1501
|
-
|
|
1502
|
-
const webhookToken =
|
|
1503
|
-
const frameToken =
|
|
1504
|
-
const presenceToken =
|
|
1505
|
-
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();
|
|
1506
2123
|
const keyterms = loadDict(args.dict);
|
|
1507
2124
|
const name = botName(args.name);
|
|
1508
2125
|
const port = args.port || 8080;
|
|
@@ -1512,7 +2129,7 @@ async function cmdJoin(args, deps = {}) {
|
|
|
1512
2129
|
const videoFrameDir = resolveVideoFrameDir(args.frame_dir, false);
|
|
1513
2130
|
const videoFrameFile = resolveVideoFrameFile(args.frame_dir, false);
|
|
1514
2131
|
const oldState = loadState();
|
|
1515
|
-
for (const pidKey of ["server_pid", "ngrok_pid", "mediamtx_pid"]) {
|
|
2132
|
+
for (const pidKey of ["server_pid", "ngrok_pid", "tunnel_pid", "mediamtx_pid"]) {
|
|
1516
2133
|
const pid = oldState[pidKey];
|
|
1517
2134
|
if (typeof pid === "number" && pid) {
|
|
1518
2135
|
kill(pid, "SIGTERM");
|
|
@@ -1520,25 +2137,7 @@ async function cmdJoin(args, deps = {}) {
|
|
|
1520
2137
|
}
|
|
1521
2138
|
const selfPath = fileURLToPath2(import.meta.url);
|
|
1522
2139
|
const cliPath = selfPath.replace(/commands\/join\.ts$/, "cli.ts");
|
|
1523
|
-
const
|
|
1524
|
-
process.execPath,
|
|
1525
|
-
cliPath,
|
|
1526
|
-
"_serve",
|
|
1527
|
-
"--port",
|
|
1528
|
-
String(port),
|
|
1529
|
-
"--transcript-file",
|
|
1530
|
-
transcriptFile,
|
|
1531
|
-
"--call-id-file",
|
|
1532
|
-
stateFile()
|
|
1533
|
-
], {
|
|
1534
|
-
env: {
|
|
1535
|
-
SAMOGRAPH_WEBHOOK_TOKEN: webhookToken,
|
|
1536
|
-
SAMOGRAPH_FRAME_TOKEN: frameToken,
|
|
1537
|
-
SAMOGRAPH_PRESENCE_TOKEN: presenceToken,
|
|
1538
|
-
SAMOGRAPH_PRESENCE_WRITE_TOKEN: presenceWriteToken
|
|
1539
|
-
}
|
|
1540
|
-
});
|
|
1541
|
-
const started = new Set([server]);
|
|
2140
|
+
const started = new Set;
|
|
1542
2141
|
let stateSaved = false;
|
|
1543
2142
|
const cleanupUnsaved = () => {
|
|
1544
2143
|
if (stateSaved)
|
|
@@ -1576,15 +2175,32 @@ async function cmdJoin(args, deps = {}) {
|
|
|
1576
2175
|
if (webhookBase !== null) {
|
|
1577
2176
|
webhookBase = normalizeWebhookBase(webhookBase);
|
|
1578
2177
|
}
|
|
1579
|
-
const
|
|
2178
|
+
const useCloudflared = (args.tunnel ?? "ngrok") === "cloudflared";
|
|
2179
|
+
const ngrok = webhookBase || useCloudflared ? null : spawn(["ngrok", "http", String(port), "--log=stdout"]);
|
|
1580
2180
|
if (ngrok)
|
|
1581
2181
|
started.add(ngrok);
|
|
2182
|
+
let cloudflared = null;
|
|
1582
2183
|
try {
|
|
1583
2184
|
let webhookUrl;
|
|
1584
2185
|
if (webhookBase) {
|
|
1585
2186
|
process.stdout.write(`Using external tunnel (--webhook-base): ${webhookBase} \u2192 localhost:${port}
|
|
1586
2187
|
`);
|
|
1587
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
|
+
`);
|
|
1588
2204
|
} else {
|
|
1589
2205
|
process.stdout.write(`Starting ngrok tunnel on port ${port}...
|
|
1590
2206
|
`);
|
|
@@ -1597,6 +2213,33 @@ async function cmdJoin(args, deps = {}) {
|
|
|
1597
2213
|
throw new ExitError(1);
|
|
1598
2214
|
}
|
|
1599
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
|
+
}
|
|
1600
2243
|
const presenceBgSuffix = args.presence_bg ? `&bg=${encodeURIComponent(args.presence_bg)}` : "";
|
|
1601
2244
|
let presencePageUrl = args.no_presence ? null : `${publicBaseUrl}/presence?token=${encodeURIComponent(presenceToken)}${presenceBgSuffix}`;
|
|
1602
2245
|
webhookUrl = `${publicBaseUrl}/webhook?token=${encodeURIComponent(webhookToken)}`;
|
|
@@ -1774,6 +2417,9 @@ async function cmdJoin(args, deps = {}) {
|
|
|
1774
2417
|
newState.presence_token = presenceToken;
|
|
1775
2418
|
newState.presence_write_token = presenceWriteToken;
|
|
1776
2419
|
}
|
|
2420
|
+
if (cloudflared) {
|
|
2421
|
+
newState.tunnel_pid = cloudflared.pid;
|
|
2422
|
+
}
|
|
1777
2423
|
if (mediamtxProc) {
|
|
1778
2424
|
newState.mediamtx_pid = mediamtxProc.pid;
|
|
1779
2425
|
}
|
|
@@ -1835,6 +2481,10 @@ The agent will appear in the call within ~15 seconds.
|
|
|
1835
2481
|
`);
|
|
1836
2482
|
process.stdout.write(`--------------------------
|
|
1837
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
|
+
}
|
|
1838
2488
|
} catch (err) {
|
|
1839
2489
|
cleanupUnsaved();
|
|
1840
2490
|
throw err;
|
|
@@ -1842,7 +2492,7 @@ The agent will appear in the call within ~15 seconds.
|
|
|
1842
2492
|
}
|
|
1843
2493
|
|
|
1844
2494
|
// src/commands/leave.ts
|
|
1845
|
-
import { existsSync as existsSync6, unlinkSync, appendFileSync } from "fs";
|
|
2495
|
+
import { existsSync as existsSync6, unlinkSync, appendFileSync as appendFileSync2 } from "fs";
|
|
1846
2496
|
function defaultKill2(pid, signal) {
|
|
1847
2497
|
process.kill(pid, signal);
|
|
1848
2498
|
}
|
|
@@ -1869,12 +2519,12 @@ async function cmdLeave(args, deps = {}) {
|
|
|
1869
2519
|
if (existsSync6(transcriptFile)) {
|
|
1870
2520
|
const ts = fmtSentinelTs(now());
|
|
1871
2521
|
try {
|
|
1872
|
-
|
|
2522
|
+
appendFileSync2(transcriptFile, `[${ts}] SAMOGRAPH_CALL_ENDED
|
|
1873
2523
|
`);
|
|
1874
2524
|
} catch {}
|
|
1875
2525
|
}
|
|
1876
2526
|
}
|
|
1877
|
-
for (const pidKey of ["server_pid", "ngrok_pid", "mediamtx_pid"]) {
|
|
2527
|
+
for (const pidKey of ["server_pid", "ngrok_pid", "tunnel_pid", "mediamtx_pid"]) {
|
|
1878
2528
|
const pid = state[pidKey];
|
|
1879
2529
|
if (typeof pid === "number" && pid) {
|
|
1880
2530
|
try {
|
|
@@ -1894,7 +2544,7 @@ async function cmdLeave(args, deps = {}) {
|
|
|
1894
2544
|
}
|
|
1895
2545
|
|
|
1896
2546
|
// src/commands/status.ts
|
|
1897
|
-
import { existsSync as existsSync7, readFileSync as
|
|
2547
|
+
import { existsSync as existsSync7, readFileSync as readFileSync7, statSync } from "fs";
|
|
1898
2548
|
async function cmdStatus(args, deps = {}) {
|
|
1899
2549
|
const recall = deps.recall ?? makeRecallClient();
|
|
1900
2550
|
const fetchFn = deps.fetchFn ?? fetch;
|
|
@@ -1912,7 +2562,7 @@ async function cmdStatus(args, deps = {}) {
|
|
|
1912
2562
|
const state = loadState();
|
|
1913
2563
|
const tf = typeof state.transcript_file === "string" ? state.transcript_file : defaultTranscriptFile();
|
|
1914
2564
|
if (existsSync7(tf)) {
|
|
1915
|
-
const lines =
|
|
2565
|
+
const lines = readFileSync7(tf, "utf-8").split(/\r?\n/).filter((l) => l.trim() && !SENTINEL_RE.test(l));
|
|
1916
2566
|
process.stdout.write(`Transcript lines so far: ${lines.length}
|
|
1917
2567
|
`);
|
|
1918
2568
|
if (lines.length) {
|
|
@@ -1993,7 +2643,7 @@ async function cmdStatus(args, deps = {}) {
|
|
|
1993
2643
|
|
|
1994
2644
|
// src/commands/screenshot.ts
|
|
1995
2645
|
import { resolve as resolve2 } from "path";
|
|
1996
|
-
var
|
|
2646
|
+
var sleep5 = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
1997
2647
|
function defaultRun(cmd) {
|
|
1998
2648
|
const proc = Bun.spawnSync(cmd);
|
|
1999
2649
|
return { exitCode: proc.exitCode };
|
|
@@ -2035,7 +2685,7 @@ async function cmdScreenshot(args, deps = {}) {
|
|
|
2035
2685
|
const meetingUrl = state.meeting_url ?? "";
|
|
2036
2686
|
if (meetingUrl.includes("meet.google.com") || meetingUrl.includes("zoom.us")) {
|
|
2037
2687
|
focus(meetingUrl);
|
|
2038
|
-
await
|
|
2688
|
+
await sleep5(1000);
|
|
2039
2689
|
}
|
|
2040
2690
|
const result = run(["screencapture", "-x", out]);
|
|
2041
2691
|
if (result.exitCode !== 0) {
|
|
@@ -2103,21 +2753,8 @@ async function cmdTranscript(args, deps = {}) {
|
|
|
2103
2753
|
}
|
|
2104
2754
|
}
|
|
2105
2755
|
|
|
2106
|
-
// src/commands/chat.ts
|
|
2107
|
-
async function cmdChat(args, deps = {}) {
|
|
2108
|
-
const recall = deps.recall ?? makeRecallClient();
|
|
2109
|
-
const bid = botIdFromArgsOrState(args.bot_id);
|
|
2110
|
-
const resp = await recall.sendChat(bid, args.message ?? "");
|
|
2111
|
-
if (!resp.ok) {
|
|
2112
|
-
const body = await resp.text().catch(() => "");
|
|
2113
|
-
throw new Error(`send_chat_message failed: ${resp.status} ${body}`);
|
|
2114
|
-
}
|
|
2115
|
-
process.stdout.write(`Sent: ${args.message}
|
|
2116
|
-
`);
|
|
2117
|
-
}
|
|
2118
|
-
|
|
2119
2756
|
// src/commands/frame.ts
|
|
2120
|
-
import { existsSync as existsSync8, readFileSync as
|
|
2757
|
+
import { existsSync as existsSync8, readFileSync as readFileSync8, writeFileSync as writeFileSync6 } from "fs";
|
|
2121
2758
|
import { dirname as dirname4, resolve as resolve3 } from "path";
|
|
2122
2759
|
function defaultRun2(cmd) {
|
|
2123
2760
|
const proc = Bun.spawnSync(cmd, { timeout: 15000 });
|
|
@@ -2215,10 +2852,10 @@ async function cmdFrame(args, deps = {}) {
|
|
|
2215
2852
|
if (typeof legacyFrameFile === "string" && legacyFrameFile && existsSync8(legacyFrameFile)) {
|
|
2216
2853
|
const output = archive && !args.out ? archiveExistingFrame(legacyFrameFile) : out;
|
|
2217
2854
|
if (!(archive && !args.out)) {
|
|
2218
|
-
|
|
2855
|
+
writeFileSync6(output, readFileSync8(legacyFrameFile));
|
|
2219
2856
|
const metadataFile = frameMetadataPath(legacyFrameFile);
|
|
2220
2857
|
if (existsSync8(metadataFile)) {
|
|
2221
|
-
|
|
2858
|
+
writeFileSync6(frameMetadataPath(output), readFileSync8(metadataFile));
|
|
2222
2859
|
}
|
|
2223
2860
|
}
|
|
2224
2861
|
process.stdout.write(resolve3(output) + `
|
|
@@ -2230,7 +2867,7 @@ async function cmdFrame(args, deps = {}) {
|
|
|
2230
2867
|
const contentType = resp.headers.get("content-type") ?? "";
|
|
2231
2868
|
if (resp.status === 200 && contentType.startsWith("image/")) {
|
|
2232
2869
|
const buf = new Uint8Array(await resp.arrayBuffer());
|
|
2233
|
-
|
|
2870
|
+
writeFileSync6(out, buf);
|
|
2234
2871
|
process.stdout.write(resolve3(out) + `
|
|
2235
2872
|
`);
|
|
2236
2873
|
return;
|
|
@@ -2299,8 +2936,8 @@ async function cmdFrames(deps = {}) {
|
|
|
2299
2936
|
}
|
|
2300
2937
|
|
|
2301
2938
|
// src/commands/dicts.ts
|
|
2302
|
-
import { existsSync as existsSync9, readdirSync, readFileSync as
|
|
2303
|
-
import { join as
|
|
2939
|
+
import { existsSync as existsSync9, readdirSync, readFileSync as readFileSync9 } from "fs";
|
|
2940
|
+
import { join as join7, basename } from "path";
|
|
2304
2941
|
async function cmdDicts() {
|
|
2305
2942
|
const dir = dictDir();
|
|
2306
2943
|
if (!existsSync9(dir)) {
|
|
@@ -2308,7 +2945,7 @@ async function cmdDicts() {
|
|
|
2308
2945
|
`);
|
|
2309
2946
|
return;
|
|
2310
2947
|
}
|
|
2311
|
-
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));
|
|
2312
2949
|
if (!files.length) {
|
|
2313
2950
|
process.stdout.write(`No dictionaries found.
|
|
2314
2951
|
`);
|
|
@@ -2317,7 +2954,7 @@ async function cmdDicts() {
|
|
|
2317
2954
|
process.stdout.write(`Available dictionaries:
|
|
2318
2955
|
`);
|
|
2319
2956
|
for (const f of files) {
|
|
2320
|
-
const terms =
|
|
2957
|
+
const terms = readFileSync9(f, "utf-8").split(/\r?\n/).map((l) => l.trim()).filter((l) => l.length > 0);
|
|
2321
2958
|
const stem = basename(f, ".txt");
|
|
2322
2959
|
process.stdout.write(` ${stem} (${terms.length} terms)
|
|
2323
2960
|
`);
|
|
@@ -2329,228 +2966,25 @@ async function cmdWatch() {
|
|
|
2329
2966
|
await watch();
|
|
2330
2967
|
}
|
|
2331
2968
|
|
|
2332
|
-
// src/server.ts
|
|
2333
|
-
import { appendFileSync as appendFileSync2 } from "fs";
|
|
2334
|
-
import { readFileSync as readFileSync8 } from "fs";
|
|
2335
|
-
import { Buffer as Buffer3 } from "buffer";
|
|
2336
|
-
import { createHash, timingSafeEqual } from "crypto";
|
|
2337
|
-
var WEBHOOK_MAX_BYTES = 1024 * 1024;
|
|
2338
|
-
function tokensEqual(a, b) {
|
|
2339
|
-
if (!a || !b)
|
|
2340
|
-
return false;
|
|
2341
|
-
const ha = createHash("sha256").update(a).digest();
|
|
2342
|
-
const hb = createHash("sha256").update(b).digest();
|
|
2343
|
-
return timingSafeEqual(ha, hb);
|
|
2344
|
-
}
|
|
2345
|
-
async function handleWebhook(payload, transcriptPath) {
|
|
2346
|
-
const line = formatTranscriptLine(payload);
|
|
2347
|
-
if (line !== null) {
|
|
2348
|
-
appendFileSync2(transcriptPath, line + `
|
|
2349
|
-
`);
|
|
2350
|
-
}
|
|
2351
|
-
return line;
|
|
2352
|
-
}
|
|
2353
|
-
function selectedFrame(latest, bySource, source) {
|
|
2354
|
-
const key = normalizeFrameSource(source);
|
|
2355
|
-
return key ? bySource.get(key) ?? { raw: null, metadata: null } : latest;
|
|
2356
|
-
}
|
|
2357
|
-
function frameInventory(bySource) {
|
|
2358
|
-
const seen = new Set;
|
|
2359
|
-
const frames = [];
|
|
2360
|
-
for (const frame of bySource.values()) {
|
|
2361
|
-
const sourceKey = frame.metadata?.source_key;
|
|
2362
|
-
if (!frame.metadata || !sourceKey || seen.has(sourceKey))
|
|
2363
|
-
continue;
|
|
2364
|
-
seen.add(sourceKey);
|
|
2365
|
-
frames.push(frame.metadata);
|
|
2366
|
-
}
|
|
2367
|
-
frames.sort((a, b) => String(a.source_key).localeCompare(String(b.source_key)));
|
|
2368
|
-
return frames;
|
|
2369
|
-
}
|
|
2370
|
-
function callIdFromStateFile(path) {
|
|
2371
|
-
if (!path)
|
|
2372
|
-
return null;
|
|
2373
|
-
try {
|
|
2374
|
-
const state = JSON.parse(readFileSync8(path, "utf-8"));
|
|
2375
|
-
return typeof state.bot_id === "string" ? state.bot_id : null;
|
|
2376
|
-
} catch {
|
|
2377
|
-
return null;
|
|
2378
|
-
}
|
|
2379
|
-
}
|
|
2380
|
-
function serve(port, transcriptPath, options = {}) {
|
|
2381
|
-
const opts = typeof options === "string" || options === null ? { webhookToken: options } : options;
|
|
2382
|
-
const latestVideoFrame = { raw: null, metadata: null };
|
|
2383
|
-
let presence = newPresenceSnapshot();
|
|
2384
|
-
const framesBySource = new Map;
|
|
2385
|
-
const frameAuthorized = (req) => tokensEqual(req.headers.get("X-Samograph-Frame-Token"), opts.frameToken);
|
|
2386
|
-
const presencePageAuthorized = (req, url) => tokensEqual(req.headers.get("X-Samograph-Presence-Token"), opts.presenceToken) || tokensEqual(url.searchParams.get("token"), opts.presenceToken);
|
|
2387
|
-
const presenceJsonAuthorized = (req) => tokensEqual(req.headers.get("X-Samograph-Presence-Token"), opts.presenceToken);
|
|
2388
|
-
const presenceWriteAuthorized = (req) => tokensEqual(req.headers.get("X-Samograph-Presence-Token"), opts.presenceWriteToken);
|
|
2389
|
-
return Bun.serve({
|
|
2390
|
-
port,
|
|
2391
|
-
hostname: "127.0.0.1",
|
|
2392
|
-
maxRequestBodySize: WEBHOOK_MAX_BYTES,
|
|
2393
|
-
async fetch(req, server) {
|
|
2394
|
-
const url = new URL(req.url);
|
|
2395
|
-
if (req.method === "POST" && url.pathname === "/webhook") {
|
|
2396
|
-
if (!tokensEqual(url.searchParams.get("token"), opts.webhookToken)) {
|
|
2397
|
-
return Response.json({ error: "forbidden" }, { status: 403 });
|
|
2398
|
-
}
|
|
2399
|
-
const contentLength = req.headers.get("content-length");
|
|
2400
|
-
if (contentLength !== null && Number(contentLength) > WEBHOOK_MAX_BYTES) {
|
|
2401
|
-
return Response.json({ error: "payload too large" }, { status: 413 });
|
|
2402
|
-
}
|
|
2403
|
-
let payload = {};
|
|
2404
|
-
try {
|
|
2405
|
-
const body = await req.text();
|
|
2406
|
-
if (new TextEncoder().encode(body).byteLength > WEBHOOK_MAX_BYTES) {
|
|
2407
|
-
return Response.json({ error: "payload too large" }, { status: 413 });
|
|
2408
|
-
}
|
|
2409
|
-
payload = body ? JSON.parse(body) : {};
|
|
2410
|
-
} catch {
|
|
2411
|
-
payload = {};
|
|
2412
|
-
}
|
|
2413
|
-
const transcriptLine = await handleWebhook(payload, transcriptPath);
|
|
2414
|
-
if (transcriptLine !== null) {
|
|
2415
|
-
const activity = activityFromTranscriptLine(transcriptLine);
|
|
2416
|
-
if (activity !== null) {
|
|
2417
|
-
presence = appendPresenceActivity(presence, activity);
|
|
2418
|
-
}
|
|
2419
|
-
}
|
|
2420
|
-
return Response.json({ ok: true });
|
|
2421
|
-
}
|
|
2422
|
-
if (req.method === "GET" && url.pathname === "/frame") {
|
|
2423
|
-
if (!frameAuthorized(req)) {
|
|
2424
|
-
return new Response("", { status: 403 });
|
|
2425
|
-
}
|
|
2426
|
-
const frame = selectedFrame(latestVideoFrame, framesBySource, url.searchParams.get("source"));
|
|
2427
|
-
if (frame.raw === null) {
|
|
2428
|
-
return new Response("", { status: 404 });
|
|
2429
|
-
}
|
|
2430
|
-
return new Response(frame.raw, {
|
|
2431
|
-
headers: { "Content-Type": "image/png" }
|
|
2432
|
-
});
|
|
2433
|
-
}
|
|
2434
|
-
if (req.method === "GET" && url.pathname === "/frame.json") {
|
|
2435
|
-
if (!frameAuthorized(req)) {
|
|
2436
|
-
return Response.json({ error: "forbidden" }, { status: 403 });
|
|
2437
|
-
}
|
|
2438
|
-
const frame = selectedFrame(latestVideoFrame, framesBySource, url.searchParams.get("source"));
|
|
2439
|
-
if (frame.metadata === null) {
|
|
2440
|
-
return Response.json({ error: "no frame" }, { status: 404 });
|
|
2441
|
-
}
|
|
2442
|
-
return Response.json(frame.metadata);
|
|
2443
|
-
}
|
|
2444
|
-
if (req.method === "GET" && url.pathname === "/frames.json") {
|
|
2445
|
-
if (!frameAuthorized(req)) {
|
|
2446
|
-
return Response.json({ error: "forbidden" }, { status: 403 });
|
|
2447
|
-
}
|
|
2448
|
-
return Response.json({ frames: frameInventory(framesBySource) });
|
|
2449
|
-
}
|
|
2450
|
-
if (req.method === "GET" && url.pathname === "/presence") {
|
|
2451
|
-
if (!presencePageAuthorized(req, url)) {
|
|
2452
|
-
return new Response("", { status: 403 });
|
|
2453
|
-
}
|
|
2454
|
-
return new Response(presencePageHtml(), {
|
|
2455
|
-
headers: {
|
|
2456
|
-
"Content-Type": "text/html; charset=utf-8",
|
|
2457
|
-
"Cache-Control": "no-store"
|
|
2458
|
-
}
|
|
2459
|
-
});
|
|
2460
|
-
}
|
|
2461
|
-
if (req.method === "GET" && url.pathname === "/presence.json") {
|
|
2462
|
-
if (!presenceJsonAuthorized(req)) {
|
|
2463
|
-
return Response.json({ error: "forbidden" }, { status: 403 });
|
|
2464
|
-
}
|
|
2465
|
-
return Response.json(presence, {
|
|
2466
|
-
headers: { "Cache-Control": "no-store" }
|
|
2467
|
-
});
|
|
2468
|
-
}
|
|
2469
|
-
if (req.method === "POST" && url.pathname === "/presence") {
|
|
2470
|
-
if (!presenceWriteAuthorized(req)) {
|
|
2471
|
-
return Response.json({ error: "forbidden" }, { status: 403 });
|
|
2472
|
-
}
|
|
2473
|
-
const contentLength = req.headers.get("content-length");
|
|
2474
|
-
if (contentLength !== null && Number(contentLength) > WEBHOOK_MAX_BYTES) {
|
|
2475
|
-
return Response.json({ error: "payload too large" }, { status: 413 });
|
|
2476
|
-
}
|
|
2477
|
-
let payload = {};
|
|
2478
|
-
try {
|
|
2479
|
-
const body = await req.text();
|
|
2480
|
-
if (new TextEncoder().encode(body).byteLength > WEBHOOK_MAX_BYTES) {
|
|
2481
|
-
return Response.json({ error: "payload too large" }, { status: 413 });
|
|
2482
|
-
}
|
|
2483
|
-
payload = body ? JSON.parse(body) : {};
|
|
2484
|
-
} catch {
|
|
2485
|
-
payload = {};
|
|
2486
|
-
}
|
|
2487
|
-
const rawPayload = payload;
|
|
2488
|
-
const state = normalizePresenceState(rawPayload.state);
|
|
2489
|
-
if (state === null) {
|
|
2490
|
-
return Response.json({ error: "invalid presence state" }, { status: 400 });
|
|
2491
|
-
}
|
|
2492
|
-
const hasMessage = typeof rawPayload.message === "string" && sanitizePresenceText(rawPayload.message) !== "";
|
|
2493
|
-
const message = hasMessage ? sanitizePresenceMessage(rawPayload.message, state) : defaultPresenceMessage(state);
|
|
2494
|
-
if (hasMessage) {
|
|
2495
|
-
presence = appendPresenceActivity(presence, {
|
|
2496
|
-
kind: activityKindForState(state),
|
|
2497
|
-
label: labelForPresenceState(state),
|
|
2498
|
-
text: message
|
|
2499
|
-
});
|
|
2500
|
-
}
|
|
2501
|
-
presence = newPresenceSnapshot(state, message, presence.activities);
|
|
2502
|
-
return Response.json({ ok: true, presence });
|
|
2503
|
-
}
|
|
2504
|
-
if (url.pathname === "/video-ws") {
|
|
2505
|
-
if (!tokensEqual(url.searchParams.get("token"), opts.frameToken)) {
|
|
2506
|
-
return new Response("", { status: 403 });
|
|
2507
|
-
}
|
|
2508
|
-
const upgraded = server.upgrade(req);
|
|
2509
|
-
if (upgraded)
|
|
2510
|
-
return;
|
|
2511
|
-
return new Response("Upgrade Required", { status: 426 });
|
|
2512
|
-
}
|
|
2513
|
-
return new Response("Not Found", { status: 404 });
|
|
2514
|
-
},
|
|
2515
|
-
websocket: {
|
|
2516
|
-
message(_ws, message) {
|
|
2517
|
-
let payload;
|
|
2518
|
-
try {
|
|
2519
|
-
const text = typeof message === "string" ? message : Buffer3.from(message).toString("utf-8");
|
|
2520
|
-
payload = JSON.parse(text);
|
|
2521
|
-
} catch {
|
|
2522
|
-
return;
|
|
2523
|
-
}
|
|
2524
|
-
const decoded = decodeVideoSeparatePng(payload, opts.currentCallId?.() ?? null);
|
|
2525
|
-
if (decoded === null)
|
|
2526
|
-
return;
|
|
2527
|
-
latestVideoFrame.raw = decoded.raw;
|
|
2528
|
-
latestVideoFrame.metadata = decoded.metadata;
|
|
2529
|
-
const frame = { raw: decoded.raw, metadata: decoded.metadata };
|
|
2530
|
-
for (const alias of frameSourceAliases(decoded.metadata)) {
|
|
2531
|
-
framesBySource.set(alias, frame);
|
|
2532
|
-
}
|
|
2533
|
-
}
|
|
2534
|
-
}
|
|
2535
|
-
});
|
|
2536
|
-
}
|
|
2537
|
-
|
|
2538
2969
|
// src/commands/serve.ts
|
|
2539
2970
|
function resolveServeOptions(args, env = process.env) {
|
|
2540
2971
|
return {
|
|
2541
2972
|
webhookToken: args.webhook_token || env.SAMOGRAPH_WEBHOOK_TOKEN || "",
|
|
2542
2973
|
frameToken: args.frame_token || env.SAMOGRAPH_FRAME_TOKEN || "",
|
|
2543
2974
|
presenceToken: args.presence_token || env.SAMOGRAPH_PRESENCE_TOKEN || "",
|
|
2544
|
-
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 || ""
|
|
2545
2977
|
};
|
|
2546
2978
|
}
|
|
2547
2979
|
async function cmdServe(args) {
|
|
2548
2980
|
const port = args.port || 8080;
|
|
2549
2981
|
const transcriptPath = args.transcript_file;
|
|
2982
|
+
const { publicBase, ...tokens } = resolveServeOptions(args);
|
|
2550
2983
|
serve(port, transcriptPath, {
|
|
2551
|
-
...
|
|
2984
|
+
...tokens,
|
|
2552
2985
|
currentCallId: () => callIdFromStateFile(args.call_id_file)
|
|
2553
2986
|
});
|
|
2987
|
+
startTunnelWatchdog({ publicBase, transcriptPath });
|
|
2554
2988
|
await new Promise(() => {});
|
|
2555
2989
|
}
|
|
2556
2990
|
|
|
@@ -2622,7 +3056,7 @@ async function cmdDoctor() {
|
|
|
2622
3056
|
}
|
|
2623
3057
|
|
|
2624
3058
|
// src/googleDocs.ts
|
|
2625
|
-
import { existsSync as existsSync11, readFileSync as
|
|
3059
|
+
import { existsSync as existsSync11, readFileSync as readFileSync10 } from "fs";
|
|
2626
3060
|
import { createSign } from "crypto";
|
|
2627
3061
|
var DOCS_SCOPE = "https://www.googleapis.com/auth/documents";
|
|
2628
3062
|
var TOKEN_URI = "https://oauth2.googleapis.com/token";
|
|
@@ -2650,7 +3084,7 @@ function loadServiceAccountCredentials(path) {
|
|
|
2650
3084
|
if (!existsSync11(rawPath)) {
|
|
2651
3085
|
throw new Error(`Google credentials file not found: ${rawPath}`);
|
|
2652
3086
|
}
|
|
2653
|
-
const parsed = JSON.parse(
|
|
3087
|
+
const parsed = JSON.parse(readFileSync10(rawPath, "utf-8"));
|
|
2654
3088
|
if (!parsed.client_email || !parsed.private_key) {
|
|
2655
3089
|
throw new Error("Google credentials file must contain client_email and private_key");
|
|
2656
3090
|
}
|
|
@@ -2936,14 +3370,16 @@ Put your AI agent in Zoom and Google Meet calls.
|
|
|
2936
3370
|
samograph joins through Recall.ai, streams live transcript lines,
|
|
2937
3371
|
captures call frames on demand, and sends explicit chat messages.
|
|
2938
3372
|
|
|
2939
|
-
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.
|
|
2940
3375
|
|
|
2941
3376
|
commands:
|
|
2942
|
-
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]
|
|
2943
3378
|
leave [bot_id]
|
|
2944
3379
|
status [bot_id]
|
|
2945
3380
|
screenshot [--out FILE] [bot_id]
|
|
2946
3381
|
chat <message> [--bot-id ID]
|
|
3382
|
+
intro [--intro-text TEXT] [--context] [--bot-id ID]
|
|
2947
3383
|
presence <listening|thinking|speaking|acting|idle> [message]
|
|
2948
3384
|
transcript [--local] [--file FILE] [--cursor N] [--limit N] [bot_id]
|
|
2949
3385
|
dicts
|
|
@@ -2970,8 +3406,13 @@ options:
|
|
|
2970
3406
|
--transcript-dir DIR Directory for timestamped transcript files
|
|
2971
3407
|
--frame-dir DIR Directory for on-demand frame output
|
|
2972
3408
|
--no-ws-video Disable WebSocket call-frame capture
|
|
2973
|
-
--
|
|
2974
|
-
(
|
|
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
|
|
2975
3416
|
--variant NAME Recall Output Media bot size: web|web_4_core|web_gpu
|
|
2976
3417
|
(default: web_4_core; smoothest camera rendering)
|
|
2977
3418
|
--no-presence Join without the presence camera page (skips the
|
|
@@ -2980,6 +3421,11 @@ options:
|
|
|
2980
3421
|
(default: robot \u2014 static samoagent avatar image)
|
|
2981
3422
|
--rtmp Use local RTMP path through ngrok TCP
|
|
2982
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)
|
|
2983
3429
|
|
|
2984
3430
|
examples:
|
|
2985
3431
|
samograph join "https://meet.google.com/abc-defg-hij" --name Leo
|
|
@@ -3078,7 +3524,8 @@ function parseArgs(argv) {
|
|
|
3078
3524
|
const positionals = [];
|
|
3079
3525
|
const opts = {};
|
|
3080
3526
|
const valueFlags = {
|
|
3081
|
-
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"]),
|
|
3082
3529
|
leave: new Set,
|
|
3083
3530
|
status: new Set,
|
|
3084
3531
|
screenshot: new Set(["--out"]),
|
|
@@ -3091,10 +3538,11 @@ function parseArgs(argv) {
|
|
|
3091
3538
|
frame: new Set(["--out", "--source"]),
|
|
3092
3539
|
frames: new Set,
|
|
3093
3540
|
doctor: new Set,
|
|
3094
|
-
_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"])
|
|
3095
3542
|
};
|
|
3096
3543
|
const boolFlags = {
|
|
3097
|
-
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"]),
|
|
3098
3546
|
leave: new Set,
|
|
3099
3547
|
status: new Set,
|
|
3100
3548
|
screenshot: new Set,
|
|
@@ -3171,11 +3619,20 @@ function parseArgs(argv) {
|
|
|
3171
3619
|
result.rtmp = opts["--rtmp"] === true;
|
|
3172
3620
|
result.ws_video = opts["--no-ws-video"] !== true;
|
|
3173
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
|
+
}
|
|
3174
3629
|
result.no_presence = opts["--no-presence"] === true;
|
|
3175
3630
|
result.presence_bg = opts["--presence-bg"] ?? null;
|
|
3176
3631
|
if (result.presence_bg !== null && !["robot", "sphere", "field", "static", "cycle"].includes(result.presence_bg)) {
|
|
3177
3632
|
throw new ArgError(`argument --presence-bg: invalid choice: '${result.presence_bg}' (choose from robot, sphere, field, static, cycle)`);
|
|
3178
3633
|
}
|
|
3634
|
+
result.intro = opts["--intro"] === true;
|
|
3635
|
+
result.intro_text = opts["--intro-text"] ?? null;
|
|
3179
3636
|
result.frame_dir = opts["--frame-dir"] ?? null;
|
|
3180
3637
|
result.variant = opts["--variant"] ?? "web_4_core";
|
|
3181
3638
|
if (result.variant !== null && !["web", "web_4_core", "web_gpu"].includes(result.variant)) {
|
|
@@ -3227,6 +3684,12 @@ function parseArgs(argv) {
|
|
|
3227
3684
|
result.bot_id = opts["--bot-id"] ?? null;
|
|
3228
3685
|
break;
|
|
3229
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
|
+
}
|
|
3230
3693
|
case "presence": {
|
|
3231
3694
|
if (positionals.length < 1) {
|
|
3232
3695
|
throw new ArgError("the following arguments are required: state");
|
|
@@ -3273,6 +3736,7 @@ function parseArgs(argv) {
|
|
|
3273
3736
|
result.transcript_file = opts["--transcript-file"];
|
|
3274
3737
|
result.webhook_token = opts["--webhook-token"] ?? "";
|
|
3275
3738
|
result.call_id_file = opts["--call-id-file"] ?? "";
|
|
3739
|
+
result.public_base = opts["--public-base"] ?? "";
|
|
3276
3740
|
result.frame_token = opts["--frame-token"] ?? "";
|
|
3277
3741
|
result.presence_token = opts["--presence-token"] ?? "";
|
|
3278
3742
|
result.presence_write_token = opts["--presence-write-token"] ?? "";
|
|
@@ -3295,6 +3759,8 @@ async function dispatch(args) {
|
|
|
3295
3759
|
return cmdTranscript(args);
|
|
3296
3760
|
case "chat":
|
|
3297
3761
|
return cmdChat(args);
|
|
3762
|
+
case "intro":
|
|
3763
|
+
return cmdIntro(args);
|
|
3298
3764
|
case "presence":
|
|
3299
3765
|
return cmdPresence(args);
|
|
3300
3766
|
case "frame":
|