@inetafrica/open-claudia 3.5.75 → 3.5.76
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 +18 -0
- package/Dockerfile +2 -0
- package/bin/cli.js +6 -0
- package/bin/handoff.js +12 -3
- package/bin/views.js +255 -0
- package/channels/kazee/adapter.js +61 -1
- package/channels/kazee/call/camera.js +1 -2
- package/channels/kazee/call/session.js +6 -5
- package/channels/telegram/adapter.js +2 -1
- package/core/actions.js +8 -0
- package/core/lessons.js +2 -1
- package/core/loopback.js +118 -5
- package/core/migration-backup.js +6 -2
- package/core/pack-review.js +77 -10
- package/core/recall/index.js +3 -2
- package/core/recall/warm-walker.js +31 -1
- package/core/runner.js +4 -1
- package/core/system-prompt.js +2 -1
- package/core/thread-handoff.js +44 -8
- package/core/tool-guard.js +15 -8
- package/core/usage-log.js +1 -0
- package/core/views.js +310 -0
- package/docker-entrypoint.sh +10 -0
- package/package.json +2 -2
- package/test-provider-language.js +3 -1
- package/test-provider-migration-backup.js +13 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 3.5.76
|
|
4
|
+
|
|
5
|
+
3.5.75 plus the memory cuts from the usage work, the lessons ranking fix, the empty-lock fix, `handoff --new`, HTML views and the type-check guard (Sumeet 9 Nov: "all bots get all features"). Every new setting is optional: unset, a bot behaves as 3.5.75 except where noted.
|
|
6
|
+
|
|
7
|
+
- **Recall helper sleeps when idle** (`core/recall/warm-walker.js`): the warm recall walker stops after 10 minutes with no walks (about 160 MB back) and starts again on the next one. `RECALL_WARM_WALKER_IDLE_MS` changes the wait; `0` keeps it running.
|
|
8
|
+
- **Usage history read only when needed** (`core/runner.js`): a bot no longer parses its whole usage history after every turn; it reads it only when usage alerts are on.
|
|
9
|
+
- **Node flags for the bot only** (`docker-entrypoint.sh`): `OC_NODE_FLAGS` in a bot's env (for example a heap limit) goes on the main node command line, never `NODE_OPTIONS`, so agents, hooks and CLI runs do not inherit it. Unset, the start command is unchanged.
|
|
10
|
+
- **`RECALL_ENGINE` in the bot's `.env` works** (`core/recall/index.js`): the engine set in `.env` is now honoured, not only one set in the process env. A per-chat `/engine` choice still comes first.
|
|
11
|
+
- **Pack reviews wait their turn** (`core/pack-review.js`): reviews run one at a time per bot behind a queue instead of being dropped when too many came at once. `OC_REVIEW_CONCURRENCY` (default 1) and `OC_REVIEW_QUEUE_MAX` (default 20; the oldest is dropped with a log line) tune it.
|
|
12
|
+
- **Heavy libraries load on demand**: the call library (werift) loads on the first Kazee call and the Telegram library only when a Telegram token is set (about 40 MB less idle). The first call after a start takes a few seconds longer.
|
|
13
|
+
- **Type checks take their turn** (`core/tool-guard.js`): `tsc` now counts in the test-run guard, so only one test run or type check goes at a time on a bot and the free-memory floor applies.
|
|
14
|
+
- **Lessons keep their ranking** (`core/lessons.js`): reading one pack's topic lessons no longer resets every other lesson's ranking to zero.
|
|
15
|
+
- **An old empty migration lock no longer blocks start** (`core/migration-backup.js`): a lock file left empty by a run that died before writing its pid is treated as stale once old, so the bot stops crash-looping on it. A fresh empty lock still counts as held.
|
|
16
|
+
- **`open-claudia handoff --new "<title>" "<message>"`** (`core/thread-handoff.js`): starts a real titled Kazee thread, posts the hand-off in it tagging the person who asked (so it shows in their Threads list), then wakes it. Refused hand-offs start nothing.
|
|
17
|
+
- **HTML views** (`bin/views.js`, `core/views.js`): `open-claudia view send <page.html> --title "<t>" [--handler <file>] [--wake each|none]` sends an interactive page into Kazee Chat as a card with an Open button; it opens walled off (libraries from public CDNs, no data fetched from the internet). What the person does on the page runs the handler or wakes the conversation. `open-claudia view push` sends data back into open pages; `open-claudia view example` prints a quiz page and handler. Kazee Chat only.
|
|
18
|
+
|
|
19
|
+
Tests: `test-walker-idle-stop.js`, `test-usage-history-lazy.js`, `test-node-flags-entrypoint.js`, `test-recall-engine-env.js`, `test-review-queue.js`, `test-lazy-heavy-libs.js`, `test-lessons-meta-keep.js`, `test-views.js` (all in the suite), plus additions to `test-test-run-guard.js`, `test-provider-migration-backup.js` and `test-thread-handoff.js`.
|
|
20
|
+
|
|
3
21
|
## 3.5.75
|
|
4
22
|
|
|
5
23
|
3.5.74 plus personal bots act for their owner through a pass (from the Maia build), and the one-tap switch-on card. Nothing changes for a bot until Central has `bot:me` (an older Central answers 404 and the bot stays as it was). Autonomous bots are unchanged.
|
package/Dockerfile
CHANGED
|
@@ -102,5 +102,7 @@ EXPOSE 8080
|
|
|
102
102
|
# Switch to non-root user
|
|
103
103
|
USER 1001
|
|
104
104
|
|
|
105
|
+
# OC_NODE_FLAGS in a bot's env: docker-entrypoint.sh puts those flags on this
|
|
106
|
+
# node command only. Unset, the command below runs as is.
|
|
105
107
|
ENTRYPOINT ["docker-entrypoint.sh"]
|
|
106
108
|
CMD ["node", "bin/cli.js", "web"]
|
package/bin/cli.js
CHANGED
|
@@ -494,6 +494,10 @@ switch (command) {
|
|
|
494
494
|
require("./cards").runDecide(args.slice(1));
|
|
495
495
|
break;
|
|
496
496
|
}
|
|
497
|
+
case "view": {
|
|
498
|
+
require("./views").runView(args.slice(1));
|
|
499
|
+
break;
|
|
500
|
+
}
|
|
497
501
|
case "teach": {
|
|
498
502
|
Promise.resolve(require("./teach").run(args.slice(1)))
|
|
499
503
|
.catch((e) => { console.error(`teach command failed: ${e.message}`); process.exitCode = 1; });
|
|
@@ -682,6 +686,7 @@ Send tools (only work inside an active bot-spawned task):
|
|
|
682
686
|
open-claudia card pieces List the pieces a card is built from (table, tick, date, slots, rows, board...) and their examples
|
|
683
687
|
open-claudia card update <id> ... Close/open a card you sent, add a note, or replace its spec (card example <name> for specs)
|
|
684
688
|
open-claudia decide "<q>" --option id="label" ... Queue a decision card for the owner (one at a time; decide list|cancel)
|
|
689
|
+
open-claudia view send <page.html> --title "<t>" [--handler <file>] Send an interactive web page (Kazee Chat); view push|example
|
|
685
690
|
open-claudia teach list|show|finish|cancel <id> Browser recordings from Teach me; finish deletes the pictures, cancel the whole recording
|
|
686
691
|
|
|
687
692
|
Memory tools:
|
|
@@ -726,6 +731,7 @@ Multi-user / cross-channel:
|
|
|
726
731
|
open-claudia auth list|revoke <chatId> Raw authorization view + direct deauth
|
|
727
732
|
open-claudia send-to --person "<name>" "<msg>" Relay a message to another team member
|
|
728
733
|
open-claudia handoff --thread <id> "<msg>" Post in another Kazee thread and wake it to start work
|
|
734
|
+
open-claudia handoff --new "<title>" "<msg>" Start a titled thread, tag the asker, hand off into it
|
|
729
735
|
([--chat <chatId>] [--title "<label>"]; owner only)
|
|
730
736
|
open-claudia recent --person "<name>" [--limit] Read recent activity from another chat
|
|
731
737
|
|
package/bin/handoff.js
CHANGED
|
@@ -9,8 +9,12 @@ Hand work to another Kazee thread: post the message there (visibly, as the
|
|
|
9
9
|
bot) and wake that thread's conversation to start on it now.
|
|
10
10
|
|
|
11
11
|
open-claudia handoff --thread <threadId> "<message>" [--chat <chatId>] [--title "<short label>"]
|
|
12
|
+
open-claudia handoff --new "<thread title>" "<message>" [--chat <chatId>]
|
|
12
13
|
|
|
13
14
|
--thread the target thread's id (its root message id)
|
|
15
|
+
--new start a NEW titled thread (1-80 characters) and hand off into it;
|
|
16
|
+
the person who asked is tagged so it shows in their Threads list.
|
|
17
|
+
Never post a plain main-chat message and hand off onto that.
|
|
14
18
|
--chat the Kazee chat the thread is in (default: this conversation's chat)
|
|
15
19
|
--title a short label for the wake-up job (cron-list shows it)
|
|
16
20
|
|
|
@@ -35,6 +39,7 @@ async function run(argv) {
|
|
|
35
39
|
process.exit(args[0] === "--help" || args[0] === "help" ? 0 : 2);
|
|
36
40
|
}
|
|
37
41
|
const targetThreadId = takeFlag(args, "thread");
|
|
42
|
+
const newTitle = takeFlag(args, "new");
|
|
38
43
|
const targetChatId = takeFlag(args, "chat");
|
|
39
44
|
const title = takeFlag(args, "title");
|
|
40
45
|
if (args[0] && /^--[a-z]/.test(args[0])) {
|
|
@@ -42,16 +47,20 @@ async function run(argv) {
|
|
|
42
47
|
process.exit(2);
|
|
43
48
|
}
|
|
44
49
|
const message = args.join(" ").trim();
|
|
45
|
-
if (
|
|
50
|
+
if (targetThreadId && newTitle !== null) { console.error('Use --thread <threadId> or --new "<title>", not both.'); process.exit(2); }
|
|
51
|
+
if (!targetThreadId && newTitle === null) { console.error('Missing --thread <threadId> (an existing thread) or --new "<title>" (a new thread).'); process.exit(2); }
|
|
52
|
+
if (newTitle !== null && !newTitle.trim()) { console.error('--new needs a title: --new "<title>".'); process.exit(2); }
|
|
46
53
|
if (!message) { console.error("Missing message text."); process.exit(2); }
|
|
47
54
|
try {
|
|
48
|
-
const body = {
|
|
55
|
+
const body = { message };
|
|
56
|
+
if (targetThreadId) body.targetThreadId = targetThreadId;
|
|
57
|
+
if (newTitle !== null) body.newThreadTitle = newTitle;
|
|
49
58
|
if (targetChatId) body.targetChatId = targetChatId;
|
|
50
59
|
if (title) body.title = title;
|
|
51
60
|
const res = await postJson("thread-handoff", body);
|
|
52
61
|
const target = res.target || {};
|
|
53
62
|
const where = target.title ? `"${target.title}" (${target.threadId})` : `thread ${target.threadId || targetThreadId}`;
|
|
54
|
-
console.log(`Handed off to ${where}: posted message ${res.messageId || "(no id)"}, wake job ${res.jobId || "?"} (hand-off ${res.hop || "?"} of ${res.maxHops || 3} in a row).`);
|
|
63
|
+
console.log(`Handed off to ${res.newThread ? "new thread " : ""}${where}: posted message ${res.messageId || "(no id)"}, wake job ${res.jobId || "?"} (hand-off ${res.hop || "?"} of ${res.maxHops || 3} in a row).`);
|
|
55
64
|
process.exit(0);
|
|
56
65
|
} catch (e) {
|
|
57
66
|
console.error(e.message);
|
package/bin/views.js
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
// `open-claudia view ...`: HTML views in Kazee Chat from inside a turn. A
|
|
2
|
+
// view is a web page sent as a card with an Open button; it opens in a
|
|
3
|
+
// walled-off panel and talks back with kazee.send / kazee.on (core/views.js
|
|
4
|
+
// answers its events).
|
|
5
|
+
|
|
6
|
+
const fs = require("fs");
|
|
7
|
+
const path = require("path");
|
|
8
|
+
const { postJson } = require("./loopback-client");
|
|
9
|
+
|
|
10
|
+
const FILE_MAX = 2 * 1024 * 1024;
|
|
11
|
+
const PUSH_MAX = 16384;
|
|
12
|
+
|
|
13
|
+
const VIEW_USAGE = `Usage:
|
|
14
|
+
open-claudia view send <page.html> --title "<title>" [--body "<text>"] [--label "<button>"]
|
|
15
|
+
[--preview <picture.png|.jpg>] [--handler <file>] [--wake each|none]
|
|
16
|
+
open-claudia view push <messageId> <json | -> [--to <userId>]
|
|
17
|
+
open-claudia view example [page|handler]
|
|
18
|
+
Views are Kazee Chat only. The page (.html, at most 2 MB) opens in a walled-off
|
|
19
|
+
panel: it may load scripts, styles and fonts from cdn.jsdelivr.net, unpkg.com,
|
|
20
|
+
cdnjs.cloudflare.com and esm.sh, but cannot fetch anything from the internet,
|
|
21
|
+
so put its data in the page. In the page:
|
|
22
|
+
kazee.send(name, data) send an event to you (name: a-z 0-9 _ . -, data under 4 KB as JSON)
|
|
23
|
+
kazee.on(data => ...) receive what you push (view push, or a handler's {"push": ...})
|
|
24
|
+
Events wake this conversation (--wake each, the default; events close together
|
|
25
|
+
come as one wake) or are dropped (--wake none). With --handler, a script runs
|
|
26
|
+
once per event instead (node for .js, else run directly; 10 s; its folder is the
|
|
27
|
+
working directory). It gets the event as JSON on stdin
|
|
28
|
+
{"messageId","chatId","userId","userName","name","data","at"}
|
|
29
|
+
and each line it prints that is JSON is an order:
|
|
30
|
+
{"push": <data>, "to": "<userId>"} back to the page ("to" optional: only that person)
|
|
31
|
+
{"wake": "<text>"} wake this conversation
|
|
32
|
+
Never put answers you check (a quiz key) in the page: people can read it. Keep them in the handler.`;
|
|
33
|
+
|
|
34
|
+
const EXAMPLE_PAGE = `<!doctype html>
|
|
35
|
+
<html>
|
|
36
|
+
<head>
|
|
37
|
+
<meta charset="utf-8">
|
|
38
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
39
|
+
<title>Quick quiz</title>
|
|
40
|
+
<style>
|
|
41
|
+
body { font: 16px/1.4 system-ui, sans-serif; margin: 0; padding: 16px; background: #f7f7f8; color: #1d1d1f; }
|
|
42
|
+
.q { background: #fff; border-radius: 12px; padding: 14px; margin-bottom: 12px; box-shadow: 0 1px 2px rgba(0,0,0,.08); }
|
|
43
|
+
.q h3 { margin: 0 0 10px; font-size: 16px; }
|
|
44
|
+
button { display: block; width: 100%; text-align: left; margin: 6px 0; padding: 10px 12px; border: 1px solid #d0d0d6;
|
|
45
|
+
border-radius: 8px; background: #fff; font: inherit; cursor: pointer; }
|
|
46
|
+
button:disabled { cursor: default; opacity: .7; }
|
|
47
|
+
button.right { border-color: #2e9d57; background: #e7f6ec; opacity: 1; }
|
|
48
|
+
button.wrong { border-color: #d0453b; background: #fbeceb; opacity: 1; }
|
|
49
|
+
.note { font-size: 14px; color: #555; min-height: 1.2em; }
|
|
50
|
+
#score { font-weight: 600; text-align: center; }
|
|
51
|
+
</style>
|
|
52
|
+
</head>
|
|
53
|
+
<body>
|
|
54
|
+
<div id="quiz"></div>
|
|
55
|
+
<p id="score"></p>
|
|
56
|
+
<script>
|
|
57
|
+
// The questions only. The right answers stay in the handler: anyone can read this page.
|
|
58
|
+
const QUESTIONS = [
|
|
59
|
+
{ id: "q1", text: "Which planet is closest to the sun?", options: ["Venus", "Mercury", "Mars"] },
|
|
60
|
+
{ id: "q2", text: "How many legs does a spider have?", options: ["6", "8", "10"] },
|
|
61
|
+
{ id: "q3", text: "What is the boiling point of water at sea level?", options: ["90 °C", "100 °C", "120 °C"] },
|
|
62
|
+
];
|
|
63
|
+
const results = {};
|
|
64
|
+
const quiz = document.getElementById("quiz");
|
|
65
|
+
for (const q of QUESTIONS) {
|
|
66
|
+
const box = document.createElement("div");
|
|
67
|
+
box.className = "q";
|
|
68
|
+
box.innerHTML = "<h3></h3><div class='opts'></div><div class='note'></div>";
|
|
69
|
+
box.querySelector("h3").textContent = q.text;
|
|
70
|
+
q.options.forEach((label, i) => {
|
|
71
|
+
const b = document.createElement("button");
|
|
72
|
+
b.textContent = label;
|
|
73
|
+
b.onclick = () => {
|
|
74
|
+
box.querySelectorAll("button").forEach((x) => { x.disabled = true; });
|
|
75
|
+
b.dataset.picked = "1";
|
|
76
|
+
box.querySelector(".note").textContent = "Checking...";
|
|
77
|
+
window.kazee.send("answer", { q: q.id, choice: i });
|
|
78
|
+
};
|
|
79
|
+
box.querySelector(".opts").appendChild(b);
|
|
80
|
+
});
|
|
81
|
+
box.id = q.id;
|
|
82
|
+
quiz.appendChild(box);
|
|
83
|
+
}
|
|
84
|
+
// The bot's replies: { q, correct, right, note } for each answer.
|
|
85
|
+
window.kazee.on((data) => {
|
|
86
|
+
if (!data || !data.q) return;
|
|
87
|
+
const box = document.getElementById(data.q);
|
|
88
|
+
if (!box) return;
|
|
89
|
+
const buttons = box.querySelectorAll("button");
|
|
90
|
+
buttons.forEach((b, i) => {
|
|
91
|
+
if (i === data.right) b.className = "right";
|
|
92
|
+
else if (b.dataset.picked) b.className = "wrong";
|
|
93
|
+
});
|
|
94
|
+
box.querySelector(".note").textContent = data.note || "";
|
|
95
|
+
results[data.q] = !!data.correct;
|
|
96
|
+
const done = Object.keys(results).length;
|
|
97
|
+
if (done === QUESTIONS.length) {
|
|
98
|
+
const score = Object.values(results).filter(Boolean).length;
|
|
99
|
+
document.getElementById("score").textContent = "You got " + score + " of " + QUESTIONS.length + ".";
|
|
100
|
+
window.kazee.send("done", { score, of: QUESTIONS.length });
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
</script>
|
|
104
|
+
</body>
|
|
105
|
+
</html>
|
|
106
|
+
`;
|
|
107
|
+
|
|
108
|
+
const EXAMPLE_HANDLER = `// handler.js: answers the quiz page's events. Sent with
|
|
109
|
+
// open-claudia view send quiz.html --title "Quick quiz" --handler handler.js
|
|
110
|
+
// It runs once per event: the event is JSON on stdin, each JSON line printed is an order.
|
|
111
|
+
const KEY = { q1: { right: 1, note: "Mercury is closest." },
|
|
112
|
+
q2: { right: 1, note: "Spiders have 8 legs." },
|
|
113
|
+
q3: { right: 1, note: "100 °C at sea level." } };
|
|
114
|
+
|
|
115
|
+
let input = "";
|
|
116
|
+
process.stdin.on("data", (c) => { input += c; });
|
|
117
|
+
process.stdin.on("end", () => {
|
|
118
|
+
const ev = JSON.parse(input);
|
|
119
|
+
const out = (order) => console.log(JSON.stringify(order));
|
|
120
|
+
if (ev.name === "answer" && ev.data && KEY[ev.data.q]) {
|
|
121
|
+
const k = KEY[ev.data.q];
|
|
122
|
+
// Only to the person who answered: others with the page open keep their own quiz.
|
|
123
|
+
out({ push: { q: ev.data.q, correct: ev.data.choice === k.right, right: k.right, note: k.note }, to: ev.userId });
|
|
124
|
+
} else if (ev.name === "done" && ev.data) {
|
|
125
|
+
// Wake the conversation once, when someone finishes.
|
|
126
|
+
out({ wake: \`\${ev.userName || "Someone"} finished the quiz: \${Number(ev.data.score)} of \${Number(ev.data.of)}.\` });
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
`;
|
|
130
|
+
|
|
131
|
+
function flag(args, name) {
|
|
132
|
+
const i = args.indexOf(name);
|
|
133
|
+
if (i < 0) return undefined;
|
|
134
|
+
const v = args[i + 1];
|
|
135
|
+
if (v === undefined || v.startsWith("--")) throw new Error(`${name} needs a value`);
|
|
136
|
+
args.splice(i, 2);
|
|
137
|
+
return v;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function readFileFor(file, what, exts) {
|
|
141
|
+
const full = path.resolve(file);
|
|
142
|
+
const ext = path.extname(full).toLowerCase();
|
|
143
|
+
if (!exts.includes(ext)) throw new Error(`the ${what} must be a ${exts.join(" or ")} file: ${file}`);
|
|
144
|
+
let st;
|
|
145
|
+
try { st = fs.statSync(full); } catch (_) { throw new Error(`${what} not found: ${file}`); }
|
|
146
|
+
if (!st.isFile()) throw new Error(`${what} is not a file: ${file}`);
|
|
147
|
+
if (st.size === 0) throw new Error(`the ${what} is empty: ${file}`);
|
|
148
|
+
if (st.size > FILE_MAX) throw new Error(`the ${what} is over 2 MB: ${file}`);
|
|
149
|
+
return { fileName: path.basename(full), data: fs.readFileSync(full).toString("base64") };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** `view send` arguments into the loopback body (throws on bad input). */
|
|
153
|
+
function parseSend(argv) {
|
|
154
|
+
const args = [...argv];
|
|
155
|
+
const title = flag(args, "--title");
|
|
156
|
+
const body = flag(args, "--body");
|
|
157
|
+
const label = flag(args, "--label");
|
|
158
|
+
const preview = flag(args, "--preview");
|
|
159
|
+
const handler = flag(args, "--handler");
|
|
160
|
+
const wake = flag(args, "--wake");
|
|
161
|
+
const unknown = args.find((a) => a.startsWith("--"));
|
|
162
|
+
if (unknown) throw new Error(`unknown option ${unknown}`);
|
|
163
|
+
if (args.length !== 1) throw new Error(args.length ? `one page only (got ${args.join(" ")})` : "missing the page (.html file)");
|
|
164
|
+
if (!title || !title.trim()) throw new Error("--title is required");
|
|
165
|
+
if (title.trim().length > 140) throw new Error("--title is over 140 characters");
|
|
166
|
+
if (body && body.length > 600) throw new Error("--body is over 600 characters");
|
|
167
|
+
if (label && label.trim().length > 40) throw new Error("--label is over 40 characters");
|
|
168
|
+
if (wake !== undefined && !["each", "none"].includes(wake)) throw new Error("--wake must be each or none");
|
|
169
|
+
let handlerPath = null;
|
|
170
|
+
if (handler) {
|
|
171
|
+
handlerPath = path.resolve(handler);
|
|
172
|
+
let st = null;
|
|
173
|
+
try { st = fs.statSync(handlerPath); } catch (_) {}
|
|
174
|
+
if (!st || !st.isFile()) throw new Error(`handler not found: ${handler}`);
|
|
175
|
+
if (!/\.(c|m)?js$/i.test(handlerPath)) {
|
|
176
|
+
try { fs.accessSync(handlerPath, fs.constants.X_OK); }
|
|
177
|
+
catch (_) { throw new Error(`the handler is not a .js file and not executable (chmod +x it): ${handler}`); }
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return {
|
|
181
|
+
title: title.trim(),
|
|
182
|
+
...(body ? { body } : {}),
|
|
183
|
+
...(label ? { label: label.trim() } : {}),
|
|
184
|
+
...(wake ? { wake } : {}),
|
|
185
|
+
handler: handlerPath,
|
|
186
|
+
page: readFileFor(args[0], "page", [".html", ".htm"]),
|
|
187
|
+
...(preview ? { preview: readFileFor(preview, "preview", [".png", ".jpg", ".jpeg"]) } : {}),
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** `view push` arguments into the loopback body; `readStdin` for "-". */
|
|
192
|
+
function parsePush(argv, readStdin = () => fs.readFileSync(0, "utf8")) {
|
|
193
|
+
const args = [...argv];
|
|
194
|
+
const to = flag(args, "--to");
|
|
195
|
+
const unknown = args.find((a) => a.startsWith("--"));
|
|
196
|
+
if (unknown) throw new Error(`unknown option ${unknown}`);
|
|
197
|
+
const [messageId, raw] = args;
|
|
198
|
+
if (!messageId) throw new Error("missing the view's message id");
|
|
199
|
+
if (raw === undefined) throw new Error("missing the data (JSON, or - for stdin)");
|
|
200
|
+
if (args.length > 2) throw new Error("too many arguments (quote the JSON)");
|
|
201
|
+
const text = raw === "-" ? readStdin() : raw;
|
|
202
|
+
let data;
|
|
203
|
+
try { data = JSON.parse(text); }
|
|
204
|
+
catch (_) { throw new Error(`the data is not JSON (a string needs quotes: '"hello"')`); }
|
|
205
|
+
if (Buffer.byteLength(JSON.stringify(data)) > PUSH_MAX) throw new Error("the data is over 16 KB as JSON");
|
|
206
|
+
return { messageId, data, ...(to ? { to } : {}) };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function exampleText(which) {
|
|
210
|
+
if (which === "page") return EXAMPLE_PAGE;
|
|
211
|
+
if (which === "handler") return EXAMPLE_HANDLER;
|
|
212
|
+
if (which) throw new Error("examples: page, handler (or nothing for both)");
|
|
213
|
+
return [
|
|
214
|
+
"# quiz.html (open-claudia view example page > quiz.html)",
|
|
215
|
+
EXAMPLE_PAGE,
|
|
216
|
+
"# handler.js (open-claudia view example handler > handler.js)",
|
|
217
|
+
EXAMPLE_HANDLER,
|
|
218
|
+
'# Send it: open-claudia view send quiz.html --title "Quick quiz" --handler handler.js',
|
|
219
|
+
"",
|
|
220
|
+
].join("\n");
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function fail(msg, code = 1) {
|
|
224
|
+
console.error(msg);
|
|
225
|
+
process.exit(code);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async function runView(argv) {
|
|
229
|
+
const args = [...argv];
|
|
230
|
+
const sub = args.shift();
|
|
231
|
+
try {
|
|
232
|
+
if (sub === "example") {
|
|
233
|
+
process.stdout.write(exampleText(args[0]));
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
if (sub === "send") {
|
|
237
|
+
const body = parseSend(args);
|
|
238
|
+
const res = await postJson("view-send", body);
|
|
239
|
+
const events = res.handler ? `events run ${res.handler}` : `events wake this conversation: ${res.wake}`;
|
|
240
|
+
console.log(`Sent view ${res.messageId} (${events})`);
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
if (sub === "push") {
|
|
244
|
+
const body = parsePush(args);
|
|
245
|
+
await postJson("view-push", body);
|
|
246
|
+
console.log(`Pushed to view ${body.messageId}${body.to ? ` (only to ${body.to})` : ""}`);
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
fail(VIEW_USAGE, sub && sub !== "help" && sub !== "--help" ? 1 : 0);
|
|
250
|
+
} catch (e) {
|
|
251
|
+
fail(`view ${sub}: ${e.message}`);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
module.exports = { runView, parseSend, parsePush, exampleText, VIEW_USAGE, EXAMPLE_PAGE, EXAMPLE_HANDLER };
|
|
@@ -546,6 +546,25 @@ class KazeeAdapter {
|
|
|
546
546
|
raw: payload,
|
|
547
547
|
});
|
|
548
548
|
});
|
|
549
|
+
// HTML views: someone in the chat sent an event from a view page this bot
|
|
550
|
+
// sent (kazee.send in the page). Routed like a card answer; core/views.js
|
|
551
|
+
// runs the view's handler or wakes the conversation the view came from.
|
|
552
|
+
socket.on("view:event", (payload) => {
|
|
553
|
+
if (!payload || !payload.messageId) return;
|
|
554
|
+
const userId = payload.userId ? String(payload.userId) : "";
|
|
555
|
+
this._emit("action", {
|
|
556
|
+
adapter: this,
|
|
557
|
+
channelId: String(payload.chatId || ""),
|
|
558
|
+
canonicalUserId: userId ? canonicalForChannel("kazee", userId) : null,
|
|
559
|
+
userId: userId || null,
|
|
560
|
+
type: "action",
|
|
561
|
+
messageId: payload.messageId,
|
|
562
|
+
threadId: payload.threadRoot ? String(payload.threadRoot) : null,
|
|
563
|
+
action: { buttonId: `kv:${payload.messageId}`, payload: `kv:${payload.messageId}`, sourceMessageId: payload.messageId },
|
|
564
|
+
view: payload,
|
|
565
|
+
raw: payload,
|
|
566
|
+
});
|
|
567
|
+
});
|
|
549
568
|
socket.on("message:action", (payload) => {
|
|
550
569
|
const channelId = String(payload.chatId);
|
|
551
570
|
const known = this._sentMessages.has(String(payload.messageId || ""));
|
|
@@ -733,6 +752,18 @@ class KazeeAdapter {
|
|
|
733
752
|
// What the bot reads to name a thread: the root's text, the first replies
|
|
734
753
|
// (the bot's own marked) and the title the thread already has, when the
|
|
735
754
|
// server sends one. Throws like _fetchThread.
|
|
755
|
+
// Start a real thread (chat-central startThread): a titled header root the
|
|
756
|
+
// chat's bots never hear. Followers are the starter plus whoever the first
|
|
757
|
+
// reply tags, so callers tag the person the thread is for. Resolves the
|
|
758
|
+
// root message id; throws when chat-central refuses.
|
|
759
|
+
async startThread(chatId, title) {
|
|
760
|
+
const route = `/thread/${encodeURIComponent(String(chatId))}/start`;
|
|
761
|
+
const res = await withTimeout(this._request("POST", route, { userId: String(this.botUserId), title: String(title || "") }), THREAD_TITLE_TIMEOUT_MS, "Kazee thread start");
|
|
762
|
+
const root = res && ((res.message && res.message._id) || (res.data && res.data._id));
|
|
763
|
+
if (!root) throw new Error(`Kazee thread start gave no root id${res && res.error ? `: ${res.error}` : ""}`);
|
|
764
|
+
return String(root);
|
|
765
|
+
}
|
|
766
|
+
|
|
736
767
|
async threadTitleSource(chatId, rootId) {
|
|
737
768
|
const data = await this._fetchThread(String(chatId), String(rootId), THREAD_TITLE_SOURCE_LIMIT);
|
|
738
769
|
const root = data.root && typeof data.root === "object" ? data.root : null;
|
|
@@ -1435,6 +1466,9 @@ class KazeeAdapter {
|
|
|
1435
1466
|
};
|
|
1436
1467
|
if (opts.replyTo) body.replyTo = opts.replyTo;
|
|
1437
1468
|
if (buttons) body.interactive = { buttons };
|
|
1469
|
+
// People tagged as @[Name](id) in the text (a hand-off into a new thread
|
|
1470
|
+
// tags whoever asked, so it shows in their Threads list and as unread).
|
|
1471
|
+
if (Array.isArray(opts.mentions) && opts.mentions.length) body.mentions = opts.mentions.map(String);
|
|
1438
1472
|
const threadId = opts.threadId ? String(opts.threadId) : null;
|
|
1439
1473
|
if (threadId) body.threadRootId = threadId;
|
|
1440
1474
|
// Tags of other bots (./bot-handoffs.js). chat-central reads mentions out
|
|
@@ -1574,6 +1608,32 @@ class KazeeAdapter {
|
|
|
1574
1608
|
return view;
|
|
1575
1609
|
}
|
|
1576
1610
|
|
|
1611
|
+
// Upload one file as chat media (chat-central `uploadMedia`) and answer its
|
|
1612
|
+
// media id. For HTML views: the page and its preview picture go up first,
|
|
1613
|
+
// then the card names them by id. Throws when the server keeps no id.
|
|
1614
|
+
async uploadMedia(channelId, { buffer, fileName, contentType } = {}) {
|
|
1615
|
+
const upload = await this._uploadMultipart(
|
|
1616
|
+
`/chat/media/${encodeURIComponent(channelId)}`,
|
|
1617
|
+
{ media: { buffer, fileName, contentType } },
|
|
1618
|
+
);
|
|
1619
|
+
const saved = Array.isArray(upload?.media) ? upload.media[0] : null;
|
|
1620
|
+
if (!saved || !saved._id) {
|
|
1621
|
+
const why = Array.isArray(upload?.errors) && upload.errors[0]?.error ? `: ${upload.errors[0].error}` : "";
|
|
1622
|
+
throw new Error(`Kazee Chat kept no media id for ${fileName}${why}`);
|
|
1623
|
+
}
|
|
1624
|
+
return String(saved._id);
|
|
1625
|
+
}
|
|
1626
|
+
|
|
1627
|
+
// HTML views: hand data to an open view page (kazee.on in the page).
|
|
1628
|
+
// Only the message's sender may push; `to` limits it to one person.
|
|
1629
|
+
async pushView(messageId, data, to) {
|
|
1630
|
+
const body = { data: data === undefined ? null : data };
|
|
1631
|
+
if (to) body.to = String(to);
|
|
1632
|
+
const res = await this._request("PUT", `/message/${encodeURIComponent(messageId)}/view/push`, body);
|
|
1633
|
+
if (res && res.success === false) throw new Error(res.error || "view push refused");
|
|
1634
|
+
return true;
|
|
1635
|
+
}
|
|
1636
|
+
|
|
1577
1637
|
// A card as the server shows it now (its state: open, closed, decided),
|
|
1578
1638
|
// read through the one-message lookup. Null when the message is gone.
|
|
1579
1639
|
async readCard(chatId, messageId) {
|
|
@@ -2083,7 +2143,7 @@ class KazeeAdapter {
|
|
|
2083
2143
|
const boundary = `----oc-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
2084
2144
|
const parts = [];
|
|
2085
2145
|
for (const [field, info] of Object.entries(files)) {
|
|
2086
|
-
parts.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="${field}"; filename="${info.fileName}"\r\nContent-Type: application/octet-stream\r\n\r\n`));
|
|
2146
|
+
parts.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="${field}"; filename="${info.fileName}"\r\nContent-Type: ${info.contentType || "application/octet-stream"}\r\n\r\n`));
|
|
2087
2147
|
parts.push(info.buffer);
|
|
2088
2148
|
parts.push(Buffer.from("\r\n"));
|
|
2089
2149
|
}
|
|
@@ -11,7 +11,6 @@ const fs = require("fs");
|
|
|
11
11
|
const os = require("os");
|
|
12
12
|
const path = require("path");
|
|
13
13
|
const { spawn } = require("child_process");
|
|
14
|
-
const { dePacketizeRtpPackets } = require("werift");
|
|
15
14
|
|
|
16
15
|
const STILL_EVERY_S = 2;
|
|
17
16
|
const STILL_SIZE = 768;
|
|
@@ -152,7 +151,7 @@ class CameraStills {
|
|
|
152
151
|
if ((packets[index].header.sequenceNumber - packets[index - 1].header.sequenceNumber + 65536) % 65536 !== 1) return this._lost();
|
|
153
152
|
}
|
|
154
153
|
let frame;
|
|
155
|
-
try { frame = dePacketizeRtpPackets("VP8", packets); } catch (_) { return this._lost(); }
|
|
154
|
+
try { frame = require("werift").dePacketizeRtpPackets("VP8", packets); } catch (_) { return this._lost(); }
|
|
156
155
|
if (!frame?.data?.length) return;
|
|
157
156
|
const size = frame.isKeyframe ? vp8KeyframeSize(frame.data) : null;
|
|
158
157
|
if (this._needKeyframe) {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
// Loaded on the first call, so a bot that takes no calls never holds this library.
|
|
4
|
+
const werift = () => require("werift");
|
|
4
5
|
const { JanusClient } = require("./janus-client");
|
|
5
6
|
const { projectAssignedIce, supportedIceServers } = require("./access");
|
|
6
7
|
const { AdaptiveTurnDetector, OpusAudioCodec, WEBRTC_SAMPLES, downsample48To16, upsampleTo48, rmsDbfs } = require("./audio");
|
|
@@ -227,7 +228,7 @@ class KazeeCallSession {
|
|
|
227
228
|
constructor({ roomId, chatId, callerId, botUserId, janusUrl, janusToken, janusTokenExpiresAt, roomToken, roomTokenExpiresAt, managedRoom = false, refreshRoomAccess, iceServers = [], iceCredentialsExpiresAt, onTranscript, onEnded, onMediaReady,
|
|
228
229
|
maxCallMs, idleMs, checkInMs, checkInGraceMs, dropMs, dropGiveUpMs, humDb, finalWaitMs, synthesize = synthesizePcm, synthesizeStream = null, asCaller = false, greeting = null,
|
|
229
230
|
turnDecision = detectTurn, acknowledgement = selectAcknowledgement, onSpeechStart, onSpeechNoise, onEarlyWords, onUtteranceDropped,
|
|
230
|
-
JanusClientImpl = JanusClient, PeerConnectionImpl = RTCPeerConnection, SpeechStreamImpl = SpeechStream } = {}) {
|
|
231
|
+
JanusClientImpl = JanusClient, PeerConnectionImpl = werift().RTCPeerConnection, SpeechStreamImpl = SpeechStream } = {}) {
|
|
231
232
|
this.roomId = Number(roomId);
|
|
232
233
|
this.chatId = String(chatId);
|
|
233
234
|
this.callerId = String(callerId);
|
|
@@ -357,7 +358,7 @@ class KazeeCallSession {
|
|
|
357
358
|
// A feed's connection also takes the caller's camera; with the
|
|
358
359
|
// orientation extension a phone held upright gives an upright still.
|
|
359
360
|
const pc = new this.PeerConnectionImpl(camera
|
|
360
|
-
? { iceServers: this.iceServers, headerExtensions: { audio: [], video: [new RTCRtpHeaderExtensionParameters({ id: 13, uri: VIDEO_ORIENTATION_URI })] } }
|
|
361
|
+
? { iceServers: this.iceServers, headerExtensions: { audio: [], video: [new (werift().RTCRtpHeaderExtensionParameters)({ id: 13, uri: VIDEO_ORIENTATION_URI })] } }
|
|
361
362
|
: { iceServers: this.iceServers });
|
|
362
363
|
if (Number.isFinite(expiresAt)) {
|
|
363
364
|
this._transportIceExpiry.set(pc, expiresAt);
|
|
@@ -515,7 +516,7 @@ class KazeeCallSession {
|
|
|
515
516
|
this._maybeGreet();
|
|
516
517
|
}
|
|
517
518
|
});
|
|
518
|
-
const track = new MediaStreamTrack({ kind: "audio" });
|
|
519
|
+
const track = new (werift().MediaStreamTrack)({ kind: "audio" });
|
|
519
520
|
const sender = pc.addTrack(track);
|
|
520
521
|
this.publisher = { pc, track, sender, pending: [], remoteSet: false };
|
|
521
522
|
const offer = await pc.createOffer();
|
|
@@ -1046,7 +1047,7 @@ class KazeeCallSession {
|
|
|
1046
1047
|
if (frame.length < WEBRTC_SAMPLES * 2) frame = Buffer.concat([frame, Buffer.alloc(WEBRTC_SAMPLES * 2 - frame.length)]);
|
|
1047
1048
|
const opus = this.codec.encode(frame);
|
|
1048
1049
|
const payloadType = this.publisher?.sender?.codec?.payloadType || 111;
|
|
1049
|
-
this.publisher?.track?.writeRtp(new RtpPacket(new RtpHeader({
|
|
1050
|
+
this.publisher?.track?.writeRtp(new (werift().RtpPacket)(new (werift().RtpHeader)({
|
|
1050
1051
|
payloadType, sequenceNumber: this.sequence++ & 0xffff, timestamp: this.timestamp >>> 0, ssrc: this.ssrc,
|
|
1051
1052
|
}), opus));
|
|
1052
1053
|
this.timestamp = (this.timestamp + WEBRTC_SAMPLES) >>> 0;
|
|
@@ -6,7 +6,6 @@
|
|
|
6
6
|
const fs = require("fs");
|
|
7
7
|
const path = require("path");
|
|
8
8
|
const https = require("https");
|
|
9
|
-
const TelegramBot = require("node-telegram-bot-api");
|
|
10
9
|
const { TEMP_DIR, FILES_DIR, CONFIG_DIR } = require("../../core/config");
|
|
11
10
|
const { safeJoin } = require("../../core/safe-filename");
|
|
12
11
|
const { canonicalForChannel } = require("../../core/identity");
|
|
@@ -27,6 +26,8 @@ class TelegramAdapter {
|
|
|
27
26
|
// one bot) sidesteps the stale-socket trap that drove the old restart storm.
|
|
28
27
|
// We still own the agent so a heal can destroy any lingering socket.
|
|
29
28
|
this._agent = new https.Agent({ keepAlive: false });
|
|
29
|
+
// Loaded here so a bot with no Telegram token never holds this library.
|
|
30
|
+
const TelegramBot = require("node-telegram-bot-api");
|
|
30
31
|
this.bot = new TelegramBot(token, {
|
|
31
32
|
polling: { autoStart: false, params: { timeout: 30 } },
|
|
32
33
|
// Socket-idle timeout on every request (getUpdates included). With
|
package/core/actions.js
CHANGED
|
@@ -148,6 +148,14 @@ function appendMandateLine(rec, lineText, log, opts) {
|
|
|
148
148
|
}
|
|
149
149
|
|
|
150
150
|
async function handleAction(envelope) {
|
|
151
|
+
// HTML views: an event from a view page this bot sent (channels/kazee
|
|
152
|
+
// adapter, view:event). core/views.js runs its handler or wakes the
|
|
153
|
+
// conversation it came from; nothing is sent into the chat from here.
|
|
154
|
+
if (envelope.view) {
|
|
155
|
+
try { await require("./views").handleViewEvent(envelope.view, { adapter: envelope.adapter }); }
|
|
156
|
+
catch (e) { console.error("[views] handling an event failed:", e.message); }
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
151
159
|
// Kazee Cards: an answer to a card this bot sent (channels/kazee adapter,
|
|
152
160
|
// card:response). The card ledger decides what it wakes; nothing is sent
|
|
153
161
|
// into the chat from here.
|
package/core/lessons.js
CHANGED
|
@@ -184,7 +184,8 @@ function topicLessons(dir, { exclude = null, max = 3, maxChars = 900 } = {}) {
|
|
|
184
184
|
const lessons = readLessons().filter((l) => l.src === dir);
|
|
185
185
|
if (lessons.length === 0) return [];
|
|
186
186
|
const skip = exclude || new Set(injectedLessons().map((l) => l.id));
|
|
187
|
-
|
|
187
|
+
// The full list: reconciling just this pack would drop every other lesson's meta.
|
|
188
|
+
const meta = reconcileMeta();
|
|
188
189
|
const ranked = lessons.filter((l) => !skip.has(l.id)).sort((a, b) => {
|
|
189
190
|
const ma = meta[a.id] || {}, mb = meta[b.id] || {};
|
|
190
191
|
return (mb.reinforced || 0) - (ma.reinforced || 0)
|