@inetafrica/open-claudia 3.2.0 → 3.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +29 -0
- package/bin/cli.js +94 -1
- package/bin/schedule.js +24 -4
- package/channels/kazee/adapter.js +37 -3
- package/channels/kazee/call/manager.js +128 -2
- package/channels/kazee/call/session.js +66 -25
- package/channels/kazee/socket.js +3 -0
- package/channels/spaces/socket.js +3 -0
- package/channels/telegram/adapter.js +9 -5
- package/core/handlers.js +6 -2
- package/core/loopback.js +55 -1
- package/core/media.js +58 -5
- package/core/providers/utility-policy.js +4 -1
- package/core/router.js +28 -4
- package/core/runner.js +14 -3
- package/core/scheduler.js +60 -21
- package/core/socket-reconnect.js +71 -0
- package/core/system-prompt.js +10 -1
- package/package.json +3 -3
- package/test-fixtures/fake-agent-cli.js +5 -1
- package/test-kazee-call-manager.js +44 -1
- package/test-kazee-call-session.js +45 -2
- package/test-provider-language.js +20 -1
- package/test-provider-scheduler.js +80 -0
- package/test-utility-provider-policy.js +22 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,34 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## v3.2.3 — No watch can end silently: every watch carries a timeout that fires as a wake-up
|
|
4
|
+
|
|
5
|
+
- **The silent-failure hole in watches.** A watch whose check never exits 0 polled forever without a sound — if the watched thing hung, was misconfigured (the unauthenticated-`glab` watch from 2026-08-12 would never have fired), or simply never happened, the agent slept through it and the user had to notice the silence themselves. The working pattern was to pair every watch with a manual backup wakeup; that safety net is now built in.
|
|
6
|
+
- **Every new watch gets a hard expiry.** `watch-add` grows `--timeout <duration|ISO>` (90m, 6h, 1d, or an ISO datetime), default **2h**, stamped as `expiresAt` on the job. On the first poll tick at or past the deadline where the check *still* fails, the watch FIRES anyway: the woken prompt opens with `[Watch expired ...]` plus an explicit "the watched condition did NOT happen — this is a deadline wake-up, not a trigger" brief, and the chat advisory reads `⏰ Watch "label" timed out without triggering — waking anyway`. A check that passes exactly on the deadline tick still reports as a normal trigger.
|
|
7
|
+
- **Expiry ends the watch's window.** An expiry fire is one-shot even for `--repeat` watches (the woken agent re-arms if still wanted), and the expiry mark is persisted so a failed expiry fire retries with the same semantics across restarts — while a retry whose condition came true in the meantime reports as a real trigger, not an expiry. `watch-list` now shows each watch's expiry (or `EXPIRED (fires on next poll)`); the global `/watch off` master switch still pauses everything, expiry included, until re-enabled.
|
|
8
|
+
- **Backward compatible by choice.** Watches persisted before this release carry no `expiresAt` and keep their old poll-forever semantics rather than being retro-expired on upgrade (a pre-upgrade watch older than the default would otherwise fire the instant the bot restarts). `watch-list` flags these as `no expiry (pre-timeout watch)`.
|
|
9
|
+
- Regression coverage in `test-provider-scheduler.js`: future-expiry idle stays silent, lapsed-expiry fire (header, brief, advisory, removal despite `--repeat`), deadline-tick pass is a trigger, default and explicit expiry stamping, and static end-to-end wiring (scheduler default → loopback passthrough → CLI flag).
|
|
10
|
+
|
|
11
|
+
## v3.2.2 — The chip thinks from first contact, keeps thinking through the tools, and the dedup gate actually runs
|
|
12
|
+
|
|
13
|
+
- **The dead minute before the chip appeared.** The typing heartbeat only started inside `executeAdmittedRun` — but a turn-bound message first crosses the enforcer's inbound triage (an LLM call), and voice notes add a media download plus STT before that. All of it ran with zero presence: on a slow triage the chat sat silent looking ignored, then "thinking" popped up long after the work had begun. The router now starts the heartbeat the moment a turn-bound message arrives (`startPreTurnTyping` in all five inbound handlers — text, voice, audio, photo, document), covering triage, download, and transcription. Non-turn traffic (wizards, vault prompts, credential pastes, commands) never shows a false chip because the start sits after those gates.
|
|
14
|
+
- **The chip decayed to a stale "typing" during tool work.** Any `tool_start` permanently cleared `thinkingPhase`, so from the first tool call onward every 4s tick sent a stateless `typing:start` — clients rendered "typing…" through minutes of tool execution, and "thinking" never came back within a turn. Terminal and text events (`text_delta`, `text_final`, `result`, `error`) still end the thinking phase, but `tool_start` now re-enters it: streaming text reads as "typing", tool stretches read as "thinking" — which is what they are. (Kazee mobile renders a stateless tick as "typing" and auto-clears 5s after the last tick, so the 4s cadence keeps the chip alive and the state accurate on old and new clients alike.)
|
|
15
|
+
- **Handoff without a scramble.** Two starts can now coexist safely: `startTypingHeartbeat` is a singleton per chat state — a new start supersedes the old interval, and every stopper is ownership-aware, acting only while its own interval is current. The router's pre-turn heartbeat hands off to the run's own heartbeat at `executeAdmittedRun` with no gap and no double interval; a stale stopper (a router `finally` after handoff, a superseded run) is a structural no-op. `/stop`, which clears the interval directly and thereby orphans the run's stopper, now emits the explicit `typing:stop` itself in both branches. `test-typing-heartbeat.js` joins the release chain: immediate thinking tick, supersede/ownership semantics, no orphaned timers across a full tick window, and the event-steering contract pinned.
|
|
16
|
+
- **The journal dedup gate never actually ran.** v3.2.0's write-time dedup adjudicator (A1) calls its utility with `purpose: "dedup"` — a purpose `utility-policy` never registered, so every invocation died at validation with `INVALID_UTILITY_PURPOSE` and failed open, silently, by design. Log-proven on the local install. "dedup" is now a first-class purpose riding the review pipeline: `MEMORY_PROVIDER` steers it and `PACK_DEDUP_MODEL` is honored as its model override (both the prefix and the legacy Claude alias, matching what the adjudicator already passes). Pinned in `test-utility-provider-policy.js`.
|
|
17
|
+
- **Not changed, on purpose:** the 5× duplicate voice notes from 2026-08-09 were client multi-tap creating *distinct* messages (fixed client-side in kazee-chat-mobile v3.25.23's optimistic-upload guard); the router's existing `isDuplicate` LRU already covers true socket redeliveries by message id, so no bot-side dedup was added.
|
|
18
|
+
|
|
19
|
+
## v3.2.1 — Speech as a general capability, sockets that survive rejection, and calls that greet back
|
|
20
|
+
|
|
21
|
+
- **A rejected handshake no longer strands a bot forever.** socket.io-client permanently stops reconnecting when the *server* rejects a handshake (auth middleware `next(new Error())`): `connect_error` fires with `socket.active === false` and the manager never dials again. During the 2026-08-08 outage chat-central booted before Central's MongoDB and rejected every handshake for a few minutes — terminally stranding the whole bot fleet, pods "Running" but deaf until manually restarted. New `core/socket-reconnect.js` `wireTerminalReconnect()` watches for exactly that terminal state and drives a manual `connect()` with 2s→30s capped backoff; transport-level failures (where the manager retries on its own, `socket.active === true`) are left alone. A deliberate client-side `disconnect()` is intercepted — a wedged socket emits no event for it — and cancels retries until `connect()` re-arms them. Wired into both the kazee and spaces sockets; `test-socket-reconnect.js` joins the pretest chain.
|
|
22
|
+
- **The bot can now place calls, both directions get a greeting, and the media leg works behind NAT.** Call-platform batch in the kazee channel, live-tested end to end on 2026-08-08:
|
|
23
|
+
- ICE servers now come from chat-central's `GET /webrtc/config` instead of a hardcoded STUN list, filtered to `stun:`/`turn:` URLs without `transport=tcp` (werift's ICE is UDP-only), falling back to defaults if the fetch fails. This fixes the zero-RTP calls where STUN alone couldn't punch through (diagnosed 2026-07-30).
|
|
24
|
+
- `outgoing({ chatId, toUserId })` places a bot-initiated call: the caller side owns room lifecycle, so the session creates the Janus room itself (mirroring the web client's params, tolerating 427 already-exists) and destroys it on close; `call:invite` goes out with a 60s ring timer — FCM's TTL is 60s and chat-central keeps ringing calls fresh for 90s, while the mobile app auto-ends its ring UI at 30s.
|
|
25
|
+
- That 30s device auto-end fires `call:end`, which previously tore down the room while a human was still walking to the phone — a late answer (44s in the live test) then joined a destroyed room and hung on "Connecting". `ended()` now ignores end events while the call is still ringing; the ring timer and `call:rejected` own that phase.
|
|
26
|
+
- Callers heard silence until they spoke first and read the call as dead. The session now speaks a greeting exactly once, when the first remote feed comes up — both directions ("Hey, it's Claudia…").
|
|
27
|
+
- Double-subscribe race fixed: the join response and the async publishers event both list the same feed, and while `attach()` was in flight the second frame passed the `has()` check and subscribed the feed twice, orphaning a PeerConnection (diagnosed 2026-07-30). The slot is now reserved before the first await.
|
|
28
|
+
- `call:accepted`/`call:rejected` are handled, and both call-test harnesses grew coverage (+45/+47 lines) for the outgoing flow, ring timeout, ignore-while-ringing, and greeting.
|
|
29
|
+
- **Received videos and mobile uploads no longer save as `.bin`.** Telegram messages without a fileName (videos especially) and Kazee Media docs from mobile uploads (which often carry no fileName at all) fell through to the `.bin` fallback, so an mp4 landed on disk unplayable. Extension derivation is now tiered: fileName ext → transport hint (Telegram's `file_path`, which carries the real extension; Kazee's blob-URL pathname) → mimeType map (16 common types) → per-media-type default (video→.mp4, audio→.m4a/.ogg) → `.bin` only as the true last resort. Kazee documents that keep their original name get the derived extension appended when the name has none.
|
|
30
|
+
- **TTS and STT become first-class agent capabilities, not just the voice-note reply path.** Fleet agents asked to produce a voice-over or transcribe a sent recording had nothing to reach for: they probed `open-claudia say --help` (which would have *spoken* "--help"), then tried direct gateway/vendor API calls and concluded "the key is missing" — correctly, because the provider subprocess is credential-stripped by design. Two new loopback verbs run in the credentialed bot process: `tts` (`open-claudia tts "<text>" --out <path> [--voice <id>]` — synthesize to a FILE without any chat send; extension picks the container, .ogg native / .mp3 / .wav transcoded; voice override reaches the gateway for cloned voices) and `transcribe` (`open-claudia transcribe <file>` — prints the transcript of any audio OR video file; `transcribeMedia` ffmpeg-normalizes to 16kHz mono wav first, which extracts video audio tracks and shrinks the gateway upload, then falls through the existing gateway → local-Whisper chain). No guardrail on `tts` because the artifact stays on local disk — the send verbs that could ship it carry their own R8 guard. A new "Speech services (TTS / STT)" system-prompt block — gated on real capability (`canSpeak()` / new `canTranscribe()`) — documents both plus `say`, and tells agents explicitly to never call the gateway or vendor APIs directly. `say`/`tts`/`transcribe` all answer `--help` now instead of synthesizing the flag. Regression chain: `test-speech-loopback.js` pins the loopback contract (empty text 400, no route 503, synth failure 500, relative/missing transcribe paths 400, empty transcript is a valid 200, auth 401) and the media mechanics (copy vs transcode by extension, mkdir -p, voice override in the gateway body, temp-wav cleanup, prompt-block presence). Verified live: a real gateway round-trip synthesized a sentence to mp3 and transcribed it back byte-identical.
|
|
31
|
+
|
|
3
32
|
## v3.2.0 — The employee batch: memory hygiene at write time, prospective memory, earned trust, and learning from the owner's judgment calls
|
|
4
33
|
|
|
5
34
|
Three approved plans land together — memory adoption A1-A4 (from the TencentDB-Agent-Memory review), codegraph Phase 1, and employee capabilities C1-C6 — plus the Kazee group speaker-identity fix. Two rules hold throughout: **report-only where trust is involved** (nothing here grants, acts, or loosens an approval rule by itself), and **no new standing context cost** (everything injects only when already-matched content fires, or lands in the nightly dream summary).
|
package/bin/cli.js
CHANGED
|
@@ -278,6 +278,11 @@ switch (command) {
|
|
|
278
278
|
}
|
|
279
279
|
|
|
280
280
|
case "say": {
|
|
281
|
+
// Agents probe with --help before speaking; don't synthesize the flag.
|
|
282
|
+
if (args[1] === "--help" || args[1] === "-h") {
|
|
283
|
+
console.log('Usage: open-claudia say "<text>"\nSynthesize the text (TTS) and send it as a voice note to the current chat.\nTo synthesize to a file instead (voice-overs), use: open-claudia tts "<text>" --out <path>');
|
|
284
|
+
process.exit(0);
|
|
285
|
+
}
|
|
281
286
|
const text = args.slice(1).join(" ").trim();
|
|
282
287
|
if (!text) {
|
|
283
288
|
console.error('Usage: open-claudia say "<text>"');
|
|
@@ -299,6 +304,90 @@ switch (command) {
|
|
|
299
304
|
break;
|
|
300
305
|
}
|
|
301
306
|
|
|
307
|
+
case "tts": {
|
|
308
|
+
if (args[1] === "--help" || args[1] === "-h") {
|
|
309
|
+
console.log('Usage: open-claudia tts "<text>" [--out <path>.ogg|.mp3|.wav] [--voice <id>]\nSynthesize speech to a FILE without sending it to chat (voice-overs, narration).\nThe extension picks the container; prints the final path. Omit --out for a temp .ogg.');
|
|
310
|
+
process.exit(0);
|
|
311
|
+
}
|
|
312
|
+
const rest = args.slice(1);
|
|
313
|
+
let out = null;
|
|
314
|
+
let voice = null;
|
|
315
|
+
const textParts = [];
|
|
316
|
+
for (let i = 0; i < rest.length; i++) {
|
|
317
|
+
if (rest[i] === "--out") out = rest[++i];
|
|
318
|
+
else if (rest[i] === "--voice") voice = rest[++i];
|
|
319
|
+
else textParts.push(rest[i]);
|
|
320
|
+
}
|
|
321
|
+
const text = textParts.join(" ").trim();
|
|
322
|
+
if (!text) {
|
|
323
|
+
console.error('Usage: open-claudia tts "<text>" [--out <path>.ogg|.mp3|.wav] [--voice <id>]');
|
|
324
|
+
process.exit(2);
|
|
325
|
+
}
|
|
326
|
+
(async () => {
|
|
327
|
+
// Like `say`, synthesis happens in the credentialed bot process — this CLI
|
|
328
|
+
// has no speech token. The bot writes the file and returns its path.
|
|
329
|
+
const { postJson } = require("./loopback-client");
|
|
330
|
+
try {
|
|
331
|
+
const res = await postJson("tts", {
|
|
332
|
+
text,
|
|
333
|
+
out: out ? path.resolve(out) : undefined,
|
|
334
|
+
voice: voice || undefined,
|
|
335
|
+
});
|
|
336
|
+
console.log(res.path);
|
|
337
|
+
process.exit(0);
|
|
338
|
+
} catch (e) {
|
|
339
|
+
console.error(e.message);
|
|
340
|
+
process.exit(1);
|
|
341
|
+
}
|
|
342
|
+
})();
|
|
343
|
+
break;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
case "transcribe": {
|
|
347
|
+
if (args[1] === "--help" || args[1] === "-h") {
|
|
348
|
+
console.log("Usage: open-claudia transcribe <audio-or-video-file>\nSpeech-to-text: prints the transcript. Accepts audio (ogg/mp3/m4a/wav) and video\n(mp4/mkv — the audio track is extracted automatically).");
|
|
349
|
+
process.exit(0);
|
|
350
|
+
}
|
|
351
|
+
const filePath = String(args[1] || "").trim();
|
|
352
|
+
if (!filePath) {
|
|
353
|
+
console.error("Usage: open-claudia transcribe <audio-or-video-file>");
|
|
354
|
+
process.exit(2);
|
|
355
|
+
}
|
|
356
|
+
const abs = path.resolve(filePath);
|
|
357
|
+
if (!fs.existsSync(abs)) {
|
|
358
|
+
console.error(`File not found: ${abs}`);
|
|
359
|
+
process.exit(2);
|
|
360
|
+
}
|
|
361
|
+
(async () => {
|
|
362
|
+
const { postJson } = require("./loopback-client");
|
|
363
|
+
try {
|
|
364
|
+
const res = await postJson("transcribe", { path: abs });
|
|
365
|
+
console.log(res.text || "");
|
|
366
|
+
process.exit(0);
|
|
367
|
+
} catch (e) {
|
|
368
|
+
console.error(e.message);
|
|
369
|
+
process.exit(1);
|
|
370
|
+
}
|
|
371
|
+
})();
|
|
372
|
+
break;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
case "call": {
|
|
376
|
+
(async () => {
|
|
377
|
+
const { postJson } = require("./loopback-client");
|
|
378
|
+
try {
|
|
379
|
+
const toUserId = String(args[1] || "").trim();
|
|
380
|
+
const res = await postJson("call", toUserId ? { toUserId } : {});
|
|
381
|
+
console.log(`Calling: room=${res.roomId} chat=${res.chatId} to=${res.toUserId}`);
|
|
382
|
+
process.exit(0);
|
|
383
|
+
} catch (e) {
|
|
384
|
+
console.error(e.message);
|
|
385
|
+
process.exit(1);
|
|
386
|
+
}
|
|
387
|
+
})();
|
|
388
|
+
break;
|
|
389
|
+
}
|
|
390
|
+
|
|
302
391
|
case "tw":
|
|
303
392
|
case "transcript-window": {
|
|
304
393
|
require("./transcript-window").run(args.slice(1));
|
|
@@ -474,6 +563,10 @@ Send tools (only work inside an active bot-spawned task):
|
|
|
474
563
|
open-claudia send-photo <path> [caption]
|
|
475
564
|
open-claudia send-voice <path>
|
|
476
565
|
open-claudia say "<text>" Synthesize the text (TTS) and send it as a voice note
|
|
566
|
+
open-claudia tts "<text>" --out <path> Synthesize to a FILE without sending (voice-overs,
|
|
567
|
+
narration; ext picks format; --voice <id> optional)
|
|
568
|
+
open-claudia transcribe <file> Speech-to-text: print the transcript of an audio/video file
|
|
569
|
+
open-claudia call [toUserId] Place a live voice call on the current channel (kazee; owner-only)
|
|
477
570
|
|
|
478
571
|
Memory tools:
|
|
479
572
|
open-claudia transcript-search <query> Ranked FTS5 search over indexed transcripts
|
|
@@ -495,7 +588,7 @@ Background work (only inside an active bot-spawned task):
|
|
|
495
588
|
open-claudia cron-add "<sched>" "<prompt>" Recurring; each fire uses your current provider/model selection
|
|
496
589
|
open-claudia cron-list List all wakeups + crons on this channel
|
|
497
590
|
open-claudia cron-remove <id> Remove a scheduled job
|
|
498
|
-
open-claudia watch-add "<check>" "<prompt>" Silent monitor: poll a shell check, wake
|
|
591
|
+
open-claudia watch-add "<check>" "<prompt>" Silent monitor: poll a shell check, wake when it exits 0 or on --timeout expiry (default 2h)
|
|
499
592
|
open-claudia watch-list List watches on this channel + master switch state
|
|
500
593
|
open-claudia watch-remove <id> Remove a watch
|
|
501
594
|
open-claudia watch-on | watch-off Global master switch for all watches (owner)
|
package/bin/schedule.js
CHANGED
|
@@ -16,11 +16,15 @@ Crons (recurring):
|
|
|
16
16
|
open-claudia cron-remove <id> # remove by id from cron-list
|
|
17
17
|
|
|
18
18
|
Watches (silent background monitor — polls a shell check, wakes you only on a hit):
|
|
19
|
-
open-claudia watch-add "<check-cmd>" "<wake-prompt>" [--label "<label>"] [--every "<cron>"] [--repeat]
|
|
19
|
+
open-claudia watch-add "<check-cmd>" "<wake-prompt>" [--label "<label>"] [--every "<cron>"] [--repeat] [--timeout <when>]
|
|
20
20
|
<check-cmd>: a shell command polled on a schedule (default every minute).
|
|
21
21
|
exit 0 = condition met → wake the agent with <wake-prompt>.
|
|
22
22
|
non-zero = still idle → nothing happens, nothing hits the chat.
|
|
23
23
|
--repeat: keep polling after it fires (default: fire once, then remove).
|
|
24
|
+
--timeout: how long to keep polling — a duration (90m, 6h, 1d) or ISO
|
|
25
|
+
datetime. Default 2h. A watch that reaches its timeout without
|
|
26
|
+
the check ever passing FIRES anyway on the next poll, with the
|
|
27
|
+
prompt marked "Watch expired" — no watch can end silently.
|
|
24
28
|
open-claudia watch-list # watches on this channel + master switch state
|
|
25
29
|
open-claudia watch-remove <id> # remove a watch by id
|
|
26
30
|
open-claudia watch-on / watch-off # global master switch for ALL watches (owner)
|
|
@@ -141,18 +145,29 @@ async function runWatchAdd(args) {
|
|
|
141
145
|
const label = takeFlag(rest, "label");
|
|
142
146
|
const schedule = takeFlag(rest, "every");
|
|
143
147
|
const project = takeFlag(rest, "project");
|
|
148
|
+
const timeout = takeFlag(rest, "timeout");
|
|
144
149
|
const repeatIdx = rest.indexOf("--repeat");
|
|
145
150
|
const repeat = repeatIdx >= 0;
|
|
146
151
|
if (repeat) rest.splice(repeatIdx, 1);
|
|
152
|
+
let expiresAt = null;
|
|
153
|
+
if (timeout !== null) {
|
|
154
|
+
expiresAt = parseWhen(timeout);
|
|
155
|
+
if (!expiresAt) {
|
|
156
|
+
console.error(`Could not parse --timeout: "${timeout}". Use a duration (90m, 6h, 1d) or ISO datetime.`);
|
|
157
|
+
process.exit(2);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
147
160
|
const check = rest[0];
|
|
148
161
|
const prompt = rest.slice(1).join(" ");
|
|
149
|
-
if (!check || !prompt) { console.error("Usage: watch-add \"<check-cmd>\" \"<wake-prompt>\" [--every \"<cron>\"] [--repeat]"); process.exit(2); }
|
|
162
|
+
if (!check || !prompt) { console.error("Usage: watch-add \"<check-cmd>\" \"<wake-prompt>\" [--every \"<cron>\"] [--repeat] [--timeout <when>]"); process.exit(2); }
|
|
150
163
|
try {
|
|
151
164
|
const request = { check, prompt, repeat, label: label || prompt.slice(0, 60) };
|
|
152
165
|
if (schedule) request.schedule = schedule;
|
|
153
166
|
if (project) request.project = project;
|
|
167
|
+
if (expiresAt) request.expiresAt = expiresAt;
|
|
154
168
|
const res = await postJson("watch-add", request);
|
|
155
|
-
|
|
169
|
+
const expiry = res.job.expiresAt ? `, expires ${new Date(res.job.expiresAt).toISOString()}` : "";
|
|
170
|
+
console.log(`Added watch ${res.job.id} (${repeat ? "repeating" : "one-shot"}, polls "${res.job.schedule}"${expiry}). Silent until the check passes; fires as "Watch expired" if the timeout lapses first.`);
|
|
156
171
|
process.exit(0);
|
|
157
172
|
} catch (e) { console.error(e.message); process.exit(1); }
|
|
158
173
|
}
|
|
@@ -165,7 +180,12 @@ async function runWatchList() {
|
|
|
165
180
|
if (list.length === 0) { console.log("No watches on this channel."); process.exit(0); }
|
|
166
181
|
for (const w of list) {
|
|
167
182
|
const mode = w.repeat ? "repeating" : "one-shot";
|
|
168
|
-
|
|
183
|
+
const expiry = Number(w.expiresAt) > 0
|
|
184
|
+
? (Date.now() >= Number(w.expiresAt)
|
|
185
|
+
? " EXPIRED (fires on next poll)"
|
|
186
|
+
: ` expires ${new Date(Number(w.expiresAt)).toISOString()}`)
|
|
187
|
+
: " no expiry (pre-timeout watch)";
|
|
188
|
+
console.log(`${w.id} ${mode} polls "${w.schedule}"${expiry} check: ${w.check} → ${w.label || "(no label)"}`);
|
|
169
189
|
}
|
|
170
190
|
process.exit(0);
|
|
171
191
|
} catch (e) { console.error(e.message); process.exit(1); }
|
|
@@ -23,6 +23,28 @@ const CONTEXT_MAX_MESSAGES = 40;
|
|
|
23
23
|
const CONTEXT_BODY_LIMIT = 280;
|
|
24
24
|
const COLD_START_WINDOW = 15;
|
|
25
25
|
|
|
26
|
+
// Kazee Media docs from mobile uploads often carry no fileName, so the saved
|
|
27
|
+
// extension must come from the mimeType (or the blob URL) — otherwise every
|
|
28
|
+
// video/document lands on disk as ".bin".
|
|
29
|
+
const MIME_EXT = {
|
|
30
|
+
"video/mp4": ".mp4",
|
|
31
|
+
"video/quicktime": ".mov",
|
|
32
|
+
"video/webm": ".webm",
|
|
33
|
+
"video/x-matroska": ".mkv",
|
|
34
|
+
"audio/mpeg": ".mp3",
|
|
35
|
+
"audio/mp4": ".m4a",
|
|
36
|
+
"audio/x-m4a": ".m4a",
|
|
37
|
+
"audio/aac": ".aac",
|
|
38
|
+
"audio/ogg": ".ogg",
|
|
39
|
+
"audio/wav": ".wav",
|
|
40
|
+
"image/jpeg": ".jpg",
|
|
41
|
+
"image/png": ".png",
|
|
42
|
+
"image/gif": ".gif",
|
|
43
|
+
"image/webp": ".webp",
|
|
44
|
+
"application/pdf": ".pdf",
|
|
45
|
+
"application/zip": ".zip",
|
|
46
|
+
};
|
|
47
|
+
|
|
26
48
|
class KazeeAdapter {
|
|
27
49
|
constructor({ id = "kazee", url, token, ownerUserId, botUserId }) {
|
|
28
50
|
this.id = id;
|
|
@@ -600,10 +622,22 @@ class KazeeAdapter {
|
|
|
600
622
|
async downloadMedia(media) {
|
|
601
623
|
if (!media || !media.fileId) return null;
|
|
602
624
|
const url = media.fileId; // Kazee passes the cloud URL through fileId.
|
|
603
|
-
|
|
625
|
+
let urlExt = "";
|
|
626
|
+
try { urlExt = path.extname(new URL(url).pathname); } catch (e) {}
|
|
627
|
+
const mime = (media.mimeType || "").toLowerCase().split(";")[0].trim();
|
|
628
|
+
const ext = path.extname(media.fileName || "")
|
|
629
|
+
|| urlExt
|
|
630
|
+
|| MIME_EXT[mime]
|
|
631
|
+
|| (media.type === "voice" ? ".ogg"
|
|
632
|
+
: media.type === "photo" ? ".jpg"
|
|
633
|
+
: media.type === "video" ? ".mp4"
|
|
634
|
+
: media.type === "audio" ? ".m4a"
|
|
635
|
+
: ".bin");
|
|
604
636
|
const baseDir = media.type === "document" ? FILES_DIR : TEMP_DIR;
|
|
605
|
-
const
|
|
606
|
-
|
|
637
|
+
const keepName = media.type === "document" && media.fileName;
|
|
638
|
+
const nameSuffix = keepName && !path.extname(media.fileName) && ext !== ".bin" ? ext : "";
|
|
639
|
+
const localPath = keepName
|
|
640
|
+
? path.join(baseDir, media.fileName + nameSuffix)
|
|
607
641
|
: path.join(baseDir, `kz-${Date.now()}${ext}`);
|
|
608
642
|
await this._downloadUrl(url, localPath);
|
|
609
643
|
return localPath;
|
|
@@ -8,6 +8,16 @@ const DEFAULT_ICE_SERVERS = [
|
|
|
8
8
|
{ urls: "stun:coturn.kazee.africa:3478" },
|
|
9
9
|
{ urls: "stun:stun.l.google.com:19302" },
|
|
10
10
|
];
|
|
11
|
+
// FCM push TTL is 60s and chat-central treats ringing logs as fresh for 90s.
|
|
12
|
+
// Must comfortably outlast the mobile client's own 30s ring timeout: a callee
|
|
13
|
+
// device auto-ends at 30s, the human then answers late, and chat-central
|
|
14
|
+
// revives the CallLog — the room has to still exist when that answer lands
|
|
15
|
+
// (first live outbound test 2026-08-08: end at 30s, real answer at 44s).
|
|
16
|
+
const RING_TIMEOUT_MS = 60_000;
|
|
17
|
+
const OUTGOING_GREETING = "Hey, it's Claudia. Can you hear me okay?";
|
|
18
|
+
// Inbound must greet too: callers get silence until they speak first, which
|
|
19
|
+
// reads as a dead call (live test 2026-08-08 — media was up, caller hung up).
|
|
20
|
+
const INCOMING_GREETING = "Hey, it's Claudia — you're through. Can you hear me?";
|
|
11
21
|
|
|
12
22
|
class KazeeCallManager {
|
|
13
23
|
constructor({ adapter, SessionImpl = KazeeCallSession, janusUrl = process.env.KAZEE_JANUS_URL || DEFAULT_JANUS_URL,
|
|
@@ -17,12 +27,45 @@ class KazeeCallManager {
|
|
|
17
27
|
this.janusUrl = janusUrl;
|
|
18
28
|
this.iceServers = iceServers;
|
|
19
29
|
this.session = null;
|
|
30
|
+
this._ringTimer = null;
|
|
20
31
|
}
|
|
21
32
|
|
|
22
33
|
wire(socket) {
|
|
23
34
|
socket.on("call:incoming", (payload) => this.incoming(payload));
|
|
24
35
|
socket.on("call:ended", (payload) => this.ended(payload));
|
|
25
|
-
socket.on("call:rejected", (payload) => this.
|
|
36
|
+
socket.on("call:rejected", (payload) => this.rejected(payload));
|
|
37
|
+
socket.on("call:accepted", (payload) => this.accepted(payload));
|
|
38
|
+
socket.on("call:ringing", (payload) => {
|
|
39
|
+
if (this.session) console.log(`Kazee call ringing: chat=${String(payload?.chatId || "")}`);
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Janus signalling always worked; the media leg never did — the hardcoded
|
|
44
|
+
// default is STUN-only, and behind NAT only a TURN relay yields a workable
|
|
45
|
+
// candidate pair (zero RTP ever arrived, 2026-07-30). chat-central mints
|
|
46
|
+
// TURN credentials at /webrtc/config — the same endpoint the web and mobile
|
|
47
|
+
// clients use. werift's ICE stack is UDP-only, so tcp/tls variants are
|
|
48
|
+
// filtered out before they can break candidate gathering.
|
|
49
|
+
async _fetchIceServers() {
|
|
50
|
+
if (typeof this.adapter?._request !== "function") return this.iceServers;
|
|
51
|
+
try {
|
|
52
|
+
const res = await this.adapter._request("GET", "/webrtc/config");
|
|
53
|
+
const fetched = Array.isArray(res?.config?.iceServers) ? res.config.iceServers : [];
|
|
54
|
+
const usable = fetched.filter((server) => {
|
|
55
|
+
const url = String(Array.isArray(server.urls) ? server.urls[0] : server.urls || "");
|
|
56
|
+
if (url.startsWith("stun:")) return true;
|
|
57
|
+
return url.startsWith("turn:") && !url.includes("transport=tcp");
|
|
58
|
+
});
|
|
59
|
+
const turns = usable.filter((s) => String(Array.isArray(s.urls) ? s.urls[0] : s.urls).startsWith("turn:")).length;
|
|
60
|
+
if (turns > 0) {
|
|
61
|
+
console.log(`Kazee call ICE config: ${usable.length} servers (${turns} TURN) from /webrtc/config`);
|
|
62
|
+
return usable;
|
|
63
|
+
}
|
|
64
|
+
console.warn("Kazee call ICE config: /webrtc/config returned no usable TURN entry, using defaults");
|
|
65
|
+
} catch (error) {
|
|
66
|
+
console.error(`Kazee call ICE config fetch failed, using defaults: ${error.message}`);
|
|
67
|
+
}
|
|
68
|
+
return this.iceServers;
|
|
26
69
|
}
|
|
27
70
|
|
|
28
71
|
async incoming(payload = {}) {
|
|
@@ -44,9 +87,10 @@ class KazeeCallManager {
|
|
|
44
87
|
if (callType !== "audio") return reject("unsupported_media");
|
|
45
88
|
if (this.session) return reject("busy");
|
|
46
89
|
|
|
90
|
+
const iceServers = await this._fetchIceServers();
|
|
47
91
|
const session = new this.SessionImpl({
|
|
48
92
|
roomId, chatId, callerId, botUserId: this.adapter.botUserId,
|
|
49
|
-
janusUrl: this.janusUrl, iceServers:
|
|
93
|
+
janusUrl: this.janusUrl, iceServers, greeting: INCOMING_GREETING,
|
|
50
94
|
onTranscript: (text, meta) => this._transcript(text, payload, meta),
|
|
51
95
|
onEnded: () => { if (this.session === session) this.session = null; },
|
|
52
96
|
});
|
|
@@ -65,6 +109,70 @@ class KazeeCallManager {
|
|
|
65
109
|
}
|
|
66
110
|
}
|
|
67
111
|
|
|
112
|
+
// Bot-initiated call. Mirrors what a human caller's client does: create the
|
|
113
|
+
// Janus room, publish into it, then emit call:invite so chat-central rings
|
|
114
|
+
// the callee (socket + FCM/VoIP push). The callee's publisher appearing in
|
|
115
|
+
// the room is what completes the media leg — the existing auto-subscribe in
|
|
116
|
+
// the session handles that, so acceptance needs no extra media work here.
|
|
117
|
+
async outgoing({ chatId, toUserId } = {}) {
|
|
118
|
+
const target = String(toUserId || this.adapter.ownerUserId || "");
|
|
119
|
+
const chat = String(chatId || "");
|
|
120
|
+
if (!target || !chat) throw new Error("missing chatId/toUserId");
|
|
121
|
+
if (this.session) throw new Error("busy: a call session is already active");
|
|
122
|
+
if (!this.adapter._socket?.connected) throw new Error("kazee socket is not connected");
|
|
123
|
+
const roomId = Math.floor(Math.random() * 1e9);
|
|
124
|
+
const iceServers = await this._fetchIceServers();
|
|
125
|
+
// _transcript() reads chatId/fromUserId/roomId off the inbound invite
|
|
126
|
+
// payload; for outbound the remote human is the "from" side.
|
|
127
|
+
const remote = { chatId: chat, fromUserId: target, roomId, fromUserName: "" };
|
|
128
|
+
const session = new this.SessionImpl({
|
|
129
|
+
roomId, chatId: chat, callerId: target, botUserId: this.adapter.botUserId,
|
|
130
|
+
janusUrl: this.janusUrl, iceServers, asCaller: true, greeting: OUTGOING_GREETING,
|
|
131
|
+
onTranscript: (text, meta) => this._transcript(text, remote, meta),
|
|
132
|
+
onEnded: () => {
|
|
133
|
+
this._clearRingTimer();
|
|
134
|
+
if (this.session === session) this.session = null;
|
|
135
|
+
},
|
|
136
|
+
});
|
|
137
|
+
this.session = session;
|
|
138
|
+
try {
|
|
139
|
+
await session.start();
|
|
140
|
+
} catch (error) {
|
|
141
|
+
this.session = null;
|
|
142
|
+
await session.close("connect-failed").catch(() => {});
|
|
143
|
+
throw error;
|
|
144
|
+
}
|
|
145
|
+
console.log(`Kazee call outgoing: room=${roomId} chat=${chat} to=${target}`);
|
|
146
|
+
this.adapter._socket.emit("call:invite", { toUserId: target, callType: "audio", chatId: chat, roomId });
|
|
147
|
+
this._ringTimer = setTimeout(() => {
|
|
148
|
+
this._ringTimer = null;
|
|
149
|
+
this._ringTimeout(target, chat, roomId).catch((error) => console.error("Kazee call ring timeout:", error.message));
|
|
150
|
+
}, RING_TIMEOUT_MS);
|
|
151
|
+
if (typeof this._ringTimer.unref === "function") this._ringTimer.unref();
|
|
152
|
+
return { roomId, chatId: chat, toUserId: target };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
accepted(payload = {}) {
|
|
156
|
+
if (!this.session || !this._ringTimer) return;
|
|
157
|
+
console.log(`Kazee call answered: room=${this.session.roomId} chat=${String(payload.chatId || "")}`);
|
|
158
|
+
this._clearRingTimer();
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async _ringTimeout(toUserId, chatId, roomId) {
|
|
162
|
+
const session = this.session;
|
|
163
|
+
if (!session || session.roomId !== roomId) return;
|
|
164
|
+
console.log(`Kazee call unanswered: room=${roomId} to=${toUserId}`);
|
|
165
|
+
// Caller hanging up while ringing: chat-central finalizes the CallLog as
|
|
166
|
+
// missed and dismisses the callee's ringing UI/push.
|
|
167
|
+
this.adapter._socket?.emit("call:end", { toUserId, chatId });
|
|
168
|
+
this.session = null;
|
|
169
|
+
await session.close("no-answer").catch(() => {});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
_clearRingTimer() {
|
|
173
|
+
if (this._ringTimer) { clearTimeout(this._ringTimer); this._ringTimer = null; }
|
|
174
|
+
}
|
|
175
|
+
|
|
68
176
|
_transcript(text, incoming, meta = {}) {
|
|
69
177
|
const session = this.session;
|
|
70
178
|
if (!session || !text) return;
|
|
@@ -106,11 +214,29 @@ class KazeeCallManager {
|
|
|
106
214
|
async ended(payload = {}) {
|
|
107
215
|
if (!this.session) return;
|
|
108
216
|
if (payload.roomId != null && Number(payload.roomId) !== this.session.roomId) return;
|
|
217
|
+
// While our outbound ring is live, a call:ended is usually a callee DEVICE
|
|
218
|
+
// giving up (mobile auto-ends its ring at 30s), not the human declining.
|
|
219
|
+
// chat-central revives the CallLog if they answer late, so the caller must
|
|
220
|
+
// keep the room alive until its own ring timeout — tearing down here left
|
|
221
|
+
// the late answer joining a destroyed room, stuck on "Connecting" (2026-08-08).
|
|
222
|
+
// A deliberate decline arrives as call:rejected and still closes.
|
|
223
|
+
if (this._ringTimer) {
|
|
224
|
+
console.log(`Kazee call ignoring ended-while-ringing: room=${this.session.roomId}`);
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
109
227
|
const session = this.session;
|
|
110
228
|
this.session = null;
|
|
111
229
|
await session.close("remote-ended");
|
|
112
230
|
}
|
|
113
231
|
|
|
232
|
+
async rejected(payload = {}) {
|
|
233
|
+
if (!this.session) return;
|
|
234
|
+
if (payload.roomId != null && Number(payload.roomId) !== this.session.roomId) return;
|
|
235
|
+
const session = this.session;
|
|
236
|
+
this.session = null;
|
|
237
|
+
await session.close("remote-rejected");
|
|
238
|
+
}
|
|
239
|
+
|
|
114
240
|
async stop() {
|
|
115
241
|
if (!this.session) return;
|
|
116
242
|
const session = this.session;
|
|
@@ -55,12 +55,16 @@ const THINK_TICK_PCM48 = tonePcm48([{ freq: 520, ms: 90 }], 0.045);
|
|
|
55
55
|
|
|
56
56
|
class KazeeCallSession {
|
|
57
57
|
constructor({ roomId, chatId, callerId, botUserId, janusUrl, iceServers = [], onTranscript, onEnded,
|
|
58
|
-
maxCallMs, idleMs, synthesize = synthesizePcm,
|
|
58
|
+
maxCallMs, idleMs, synthesize = synthesizePcm, asCaller = false, greeting = null,
|
|
59
59
|
JanusClientImpl = JanusClient, PeerConnectionImpl = RTCPeerConnection, SpeechStreamImpl = SpeechStream } = {}) {
|
|
60
60
|
this.roomId = Number(roomId);
|
|
61
61
|
this.chatId = String(chatId);
|
|
62
62
|
this.callerId = String(callerId);
|
|
63
63
|
this.botUserId = String(botUserId);
|
|
64
|
+
this.asCaller = !!asCaller;
|
|
65
|
+
this.greeting = greeting ? String(greeting) : null;
|
|
66
|
+
this._greeted = false;
|
|
67
|
+
this._createdRoom = false;
|
|
64
68
|
this.janus = new JanusClientImpl({ url: janusUrl });
|
|
65
69
|
this.PeerConnectionImpl = PeerConnectionImpl;
|
|
66
70
|
this.SpeechStreamImpl = SpeechStreamImpl;
|
|
@@ -97,6 +101,24 @@ class KazeeCallSession {
|
|
|
97
101
|
await this.janus.connect();
|
|
98
102
|
this.publisherHandle = await this.janus.attach();
|
|
99
103
|
this.janus.onHandle(this.publisherHandle, (frame) => this._onPublisherEvent(frame));
|
|
104
|
+
if (this.asCaller) {
|
|
105
|
+
// Outbound: the caller side owns room lifecycle, mirroring the web
|
|
106
|
+
// client's create params (audio-only, so no videocodec).
|
|
107
|
+
const created = await this.janus.message(this.publisherHandle, {
|
|
108
|
+
request: "create", room: this.roomId, publishers: 6, audiocodec: "opus",
|
|
109
|
+
description: `Call room ${this.roomId}`, permanent: false,
|
|
110
|
+
// Hidden from videoroom "list": the control plane is internet-reachable,
|
|
111
|
+
// so a listable room is eavesdroppable. Join-by-roomId is unaffected.
|
|
112
|
+
is_private: true,
|
|
113
|
+
});
|
|
114
|
+
const cdata = created?.plugindata?.data || {};
|
|
115
|
+
const exists = Number(cdata.error_code) === 427 || /already exists/i.test(String(cdata.error || ""));
|
|
116
|
+
if (cdata.videoroom !== "created" && !exists) {
|
|
117
|
+
throw new Error(cdata.error || "Janus room create failed");
|
|
118
|
+
}
|
|
119
|
+
this._createdRoom = true;
|
|
120
|
+
clog(`Kazee call room created: room=${this.roomId}`);
|
|
121
|
+
}
|
|
100
122
|
// The caller creates the Janus room during its own call setup, which can
|
|
101
123
|
// land a beat after the invite reaches us — and a bot answers faster than
|
|
102
124
|
// any human. Poll join through transient 426 "No such room" like the web
|
|
@@ -163,31 +185,47 @@ class KazeeCallSession {
|
|
|
163
185
|
}
|
|
164
186
|
|
|
165
187
|
async _subscribe(feedId, display) {
|
|
188
|
+
if (this.subscribers.has(feedId)) return;
|
|
189
|
+
// Reserve the slot before the first await: the join response and the async
|
|
190
|
+
// publishers event both list the same feed, and while attach() is in
|
|
191
|
+
// flight the second frame passes the caller's has() check and subscribes
|
|
192
|
+
// this feed twice, orphaning a PeerConnection (diagnosed 2026-07-30).
|
|
193
|
+
this.subscribers.set(feedId, null);
|
|
166
194
|
clog(`Kazee call subscribing: feed=${feedId} display=${String(display || "")}`);
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
()
|
|
189
|
-
|
|
190
|
-
|
|
195
|
+
try {
|
|
196
|
+
const handle = await this.janus.attach();
|
|
197
|
+
const pc = new this.PeerConnectionImpl({ iceServers: this.iceServers });
|
|
198
|
+
pc.iceConnectionStateChange?.subscribe?.((state) => clog(`Kazee call subscriber ICE: ${state} feed=${feedId}`));
|
|
199
|
+
const sub = {
|
|
200
|
+
handle,
|
|
201
|
+
pc,
|
|
202
|
+
display: String(display || ""),
|
|
203
|
+
// Opus decoders are stateful, so each feed needs its own.
|
|
204
|
+
codec: new OpusAudioCodec(),
|
|
205
|
+
vad: new TurnDetector({ thresholdDb: BASE_THRESHOLD_DB }),
|
|
206
|
+
stt: new this.SpeechStreamImpl({ onTranscript: (text) => this._onFeedTranscript(feedId, text) }),
|
|
207
|
+
prebuffer: [],
|
|
208
|
+
};
|
|
209
|
+
this.subscribers.set(feedId, sub);
|
|
210
|
+
pc.onTrack.subscribe((track) => {
|
|
211
|
+
if (track.kind === "audio") track.onReceiveRtp.subscribe((packet) => this._onFeedRtp(feedId, packet));
|
|
212
|
+
});
|
|
213
|
+
this.janus.onHandle(handle, (frame) => this._onSubscriberEvent(feedId, frame));
|
|
214
|
+
const attached = await this.janus.message(handle, { request: "join", ptype: "subscriber", room: this.roomId, feed: feedId });
|
|
215
|
+
await this._consumeSubscriberFrame(feedId, attached);
|
|
216
|
+
await sub.stt.connect().then(
|
|
217
|
+
() => clog(`Kazee call STT connected: feed=${feedId}`),
|
|
218
|
+
(error) => console.error("Kazee call STT connect:", error.message),
|
|
219
|
+
);
|
|
220
|
+
// First remote feed up = both media legs established; greet exactly once.
|
|
221
|
+
if (this.greeting && !this._greeted) {
|
|
222
|
+
this._greeted = true;
|
|
223
|
+
this.speak(this.greeting).catch((error) => console.error("Kazee call greeting:", error.message));
|
|
224
|
+
}
|
|
225
|
+
} catch (error) {
|
|
226
|
+
if (this.subscribers.get(feedId) === null) this.subscribers.delete(feedId);
|
|
227
|
+
throw error;
|
|
228
|
+
}
|
|
191
229
|
}
|
|
192
230
|
|
|
193
231
|
async _onSubscriberEvent(feedId, frame) {
|
|
@@ -403,6 +441,9 @@ class KazeeCallSession {
|
|
|
403
441
|
this._stopThinking();
|
|
404
442
|
this.cancelSpeech();
|
|
405
443
|
for (const feed of [...this.subscribers.keys()]) await this._removeSubscriber(feed);
|
|
444
|
+
if (this._createdRoom) {
|
|
445
|
+
try { await this.janus.message(this.publisherHandle, { request: "destroy", room: this.roomId }); } catch (_) {}
|
|
446
|
+
}
|
|
406
447
|
try { await this.publisher?.pc?.close(); } catch (_) {}
|
|
407
448
|
try { await this.janus.detach(this.publisherHandle); } catch (_) {}
|
|
408
449
|
try { await this.janus.close(); } catch (_) {}
|
package/channels/kazee/socket.js
CHANGED
|
@@ -5,6 +5,8 @@ let io;
|
|
|
5
5
|
try { io = require("socket.io-client").io; }
|
|
6
6
|
catch (e) { io = null; }
|
|
7
7
|
|
|
8
|
+
const { wireTerminalReconnect } = require("../../core/socket-reconnect");
|
|
9
|
+
|
|
8
10
|
function createKazeeSocket({ url, token, userId, onConnect, onDisconnect, onError }) {
|
|
9
11
|
if (!io) {
|
|
10
12
|
throw new Error("socket.io-client is not installed. Run `npm install` to install dependencies.");
|
|
@@ -22,6 +24,7 @@ function createKazeeSocket({ url, token, userId, onConnect, onDisconnect, onErro
|
|
|
22
24
|
socket.on("connect", () => { if (onConnect) onConnect(socket); });
|
|
23
25
|
socket.on("disconnect", (reason) => { if (onDisconnect) onDisconnect(reason); });
|
|
24
26
|
socket.on("connect_error", (err) => { if (onError) onError(err); });
|
|
27
|
+
wireTerminalReconnect(socket, { label: `kazee socket (${url})` });
|
|
25
28
|
return socket;
|
|
26
29
|
}
|
|
27
30
|
|
|
@@ -14,6 +14,8 @@ let io;
|
|
|
14
14
|
try { io = require("socket.io-client").io; }
|
|
15
15
|
catch (e) { io = null; }
|
|
16
16
|
|
|
17
|
+
const { wireTerminalReconnect } = require("../../core/socket-reconnect");
|
|
18
|
+
|
|
17
19
|
function createSpacesSocket({ url, token, onNotification, onConnect, onDisconnect, onError }) {
|
|
18
20
|
if (!io) {
|
|
19
21
|
throw new Error("socket.io-client is not installed. Run `npm install` to install dependencies.");
|
|
@@ -34,6 +36,7 @@ function createSpacesSocket({ url, token, onNotification, onConnect, onDisconnec
|
|
|
34
36
|
catch (e) { console.error("spaces notification handler:", e.message); }
|
|
35
37
|
}
|
|
36
38
|
});
|
|
39
|
+
wireTerminalReconnect(socket, { label: `spaces socket (${url})` });
|
|
37
40
|
return socket;
|
|
38
41
|
}
|
|
39
42
|
|