@inetafrica/open-claudia 3.5.74 → 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 +31 -0
- package/Dockerfile +2 -0
- package/bin/cli.js +47 -0
- package/bin/handoff.js +12 -3
- package/bin/owner-setup.js +56 -0
- package/bin/views.js +255 -0
- package/bot.js +42 -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/spaces/adapter.js +25 -1
- package/channels/spaces/client.js +6 -2
- package/channels/telegram/adapter.js +2 -1
- package/channels/web/adapter.js +27 -11
- package/core/access.js +25 -0
- package/core/actions.js +8 -0
- package/core/app-pass.js +145 -0
- package/core/bot-profile.js +228 -0
- package/core/config.js +7 -0
- package/core/connected-apps.js +19 -4
- package/core/handlers.js +3 -1
- package/core/identity.js +14 -0
- package/core/lessons.js +2 -1
- package/core/loopback.js +139 -5
- package/core/migration-backup.js +6 -2
- package/core/owner-setup.js +216 -0
- 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 +22 -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/core/web-otp.js +17 -8
- package/core/web-sessions.js +11 -2
- package/docker-entrypoint.sh +10 -0
- package/package.json +2 -2
- package/test-provider-language.js +5 -1
- package/test-provider-migration-backup.js +13 -0
- package/web.js +19 -8
|
@@ -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;
|
|
@@ -21,6 +21,15 @@ const { SpacesClient, contentText } = require("./client");
|
|
|
21
21
|
const { createSpacesSocket } = require("./socket");
|
|
22
22
|
const spacesState = require("./state");
|
|
23
23
|
|
|
24
|
+
// Central's name for the Spaces back end, the app a personal bot's pass is for.
|
|
25
|
+
const SPACES_APPLICATION = "kazee-spaces-backend";
|
|
26
|
+
|
|
27
|
+
function spacesBearer(token) {
|
|
28
|
+
let personal = false;
|
|
29
|
+
try { personal = require("../../core/bot-profile").isPersonal(); } catch (_) { /* no profile: as before */ }
|
|
30
|
+
return personal ? require("../../core/app-pass").tokenFor(SPACES_APPLICATION) : token;
|
|
31
|
+
}
|
|
32
|
+
|
|
24
33
|
// Notification `type`/`category` values that warrant a full agent turn. The
|
|
25
34
|
// Spaces server emits PLURAL categories (mentions, comments, reactions,
|
|
26
35
|
// task_updated), so the engage set must match the plural forms — matching only
|
|
@@ -54,7 +63,11 @@ class SpacesAdapter {
|
|
|
54
63
|
// the only thing calling poll(); it can be disabled, so the adapter drives
|
|
55
64
|
// its own timer and never depends on it.
|
|
56
65
|
this.pollMs = Number(pollMs) > 0 ? Number(pollMs) : 60000;
|
|
57
|
-
|
|
66
|
+
// A personal bot never shows Spaces its own key: anything that still goes
|
|
67
|
+
// through this channel while it is idle (a reply, an approval mirror, a
|
|
68
|
+
// task title) carries a pass for its owner (core/app-pass.js). Other bots
|
|
69
|
+
// keep the key, as before.
|
|
70
|
+
this.client = new SpacesClient({ baseUrl: this.url, token: () => spacesBearer(token) });
|
|
58
71
|
this._listeners = { message: new Set(), action: new Set() };
|
|
59
72
|
this._socket = null;
|
|
60
73
|
this._seen = new Set(); // notification-id dedup across socket + poll
|
|
@@ -79,6 +92,17 @@ class SpacesAdapter {
|
|
|
79
92
|
}
|
|
80
93
|
|
|
81
94
|
async start() {
|
|
95
|
+
// A personal bot acts in Spaces only as its owner, through a pass
|
|
96
|
+
// (core/app-pass.js), so it has no Spaces identity of its own to watch or
|
|
97
|
+
// reply as. It watches its owner's Spaces inbox through the connected-apps
|
|
98
|
+
// plane instead, and the conversational channel stays idle.
|
|
99
|
+
try {
|
|
100
|
+
if (require("../../core/bot-profile").isPersonal()) {
|
|
101
|
+
this.idleReason = "personal-bot";
|
|
102
|
+
console.log("Spaces channel idle: a personal bot acts in Spaces only as its owner, through a pass");
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
} catch (_) { /* no profile: behave as before */ }
|
|
82
106
|
this._socket = createSpacesSocket({
|
|
83
107
|
url: this.url,
|
|
84
108
|
token: this.token,
|
|
@@ -222,13 +222,17 @@ function buildCommentContent(text, mentions = []) {
|
|
|
222
222
|
}
|
|
223
223
|
|
|
224
224
|
class SpacesClient {
|
|
225
|
+
// `token` is the bearer, or a function that returns it (sync or async): a
|
|
226
|
+
// personal bot passes core/app-pass's provider, so every call goes out with
|
|
227
|
+
// a fresh pass for its owner and never with the bot's own key.
|
|
225
228
|
constructor({ baseUrl = "https://api.spaces.kazee.africa", token }) {
|
|
226
229
|
this.baseUrl = baseUrl.replace(/\/$/, "");
|
|
227
230
|
this.token = token;
|
|
228
231
|
}
|
|
229
232
|
|
|
230
|
-
|
|
231
|
-
|
|
233
|
+
async _bearer() { return typeof this.token === "function" ? this.token() : this.token; }
|
|
234
|
+
async _get(urlPath, timeoutMs) { return request(this.baseUrl, await this._bearer(), "GET", urlPath, null, timeoutMs); }
|
|
235
|
+
async _post(urlPath, body, timeoutMs) { return request(this.baseUrl, await this._bearer(), "POST", urlPath, body, timeoutMs); }
|
|
232
236
|
|
|
233
237
|
async listTasks() {
|
|
234
238
|
const res = await this._get("/api/tasks");
|
|
@@ -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/channels/web/adapter.js
CHANGED
|
@@ -23,6 +23,20 @@ const HISTORY_LIMIT = 200;
|
|
|
23
23
|
const MEDIA_TTL_MS = 60 * 60 * 1000;
|
|
24
24
|
|
|
25
25
|
const WEB_OWNER_ID = "web-owner";
|
|
26
|
+
// A personal bot's dashboard session that names nobody (password login,
|
|
27
|
+
// Agent Space's link): not its owner.
|
|
28
|
+
const WEB_GUEST_ID = "web-guest";
|
|
29
|
+
|
|
30
|
+
// Who a dashboard message is from. An autonomous bot's dashboard is its
|
|
31
|
+
// owner's channel. A personal bot has one owner, Central's: the session
|
|
32
|
+
// names the person it was opened for (her /dashboard link), else a guest,
|
|
33
|
+
// and access.js decides whether that is the owner.
|
|
34
|
+
function webSpeaker(sessionUserId) {
|
|
35
|
+
let personal = false;
|
|
36
|
+
try { personal = require("../../core/bot-profile").isPersonal(); } catch (e) {}
|
|
37
|
+
if (!personal) return WEB_OWNER_ID;
|
|
38
|
+
return sessionUserId ? String(sessionUserId) : WEB_GUEST_ID;
|
|
39
|
+
}
|
|
26
40
|
|
|
27
41
|
class WebAdapter {
|
|
28
42
|
constructor({ id = "web" } = {}) {
|
|
@@ -64,38 +78,40 @@ class WebAdapter {
|
|
|
64
78
|
|
|
65
79
|
// ── inbound (called by web.js /api/chat routes) ─────────────────
|
|
66
80
|
|
|
67
|
-
postText(text) {
|
|
81
|
+
postText(text, { userId } = {}) {
|
|
68
82
|
const body = String(text || "");
|
|
69
83
|
if (!body.trim()) return null;
|
|
84
|
+
const speaker = webSpeaker(userId);
|
|
70
85
|
const messageId = this._mkId("t");
|
|
71
86
|
// Echo into history/stream so every connected tab sees the user's turn.
|
|
72
87
|
this._pushFrame({ kind: "message", role: "user", messageId, text: body, ts: Date.now() });
|
|
73
88
|
this._emit("message", {
|
|
74
89
|
adapter: this,
|
|
75
|
-
channelId:
|
|
76
|
-
canonicalUserId: canonicalForChannel("web",
|
|
77
|
-
userId:
|
|
90
|
+
channelId: speaker,
|
|
91
|
+
canonicalUserId: canonicalForChannel("web", speaker),
|
|
92
|
+
userId: speaker,
|
|
78
93
|
type: body.trim().startsWith("/") ? "command" : "text",
|
|
79
94
|
text: body,
|
|
80
95
|
messageId,
|
|
81
|
-
from: { id:
|
|
96
|
+
from: { id: speaker, name: speaker === WEB_OWNER_ID ? "Owner" : "", username: "" },
|
|
82
97
|
raw: { source: "web" },
|
|
83
98
|
});
|
|
84
99
|
return messageId;
|
|
85
100
|
}
|
|
86
101
|
|
|
87
|
-
postAction({ id, payload, messageId } = {}) {
|
|
102
|
+
postAction({ id, payload, messageId } = {}, { userId } = {}) {
|
|
88
103
|
const p = String(payload != null ? payload : (id != null ? id : ""));
|
|
89
104
|
if (!p) return null;
|
|
105
|
+
const speaker = webSpeaker(userId);
|
|
90
106
|
const callbackId = this._mkId("cb");
|
|
91
107
|
this._emit("action", {
|
|
92
108
|
adapter: this,
|
|
93
|
-
channelId:
|
|
94
|
-
canonicalUserId: canonicalForChannel("web",
|
|
95
|
-
userId:
|
|
109
|
+
channelId: speaker,
|
|
110
|
+
canonicalUserId: canonicalForChannel("web", speaker),
|
|
111
|
+
userId: speaker,
|
|
96
112
|
type: "action",
|
|
97
113
|
messageId,
|
|
98
|
-
from: { id:
|
|
114
|
+
from: { id: speaker, name: speaker === WEB_OWNER_ID ? "Owner" : "", username: "" },
|
|
99
115
|
action: {
|
|
100
116
|
buttonId: String(id != null ? id : p),
|
|
101
117
|
payload: p,
|
|
@@ -260,4 +276,4 @@ class WebAdapter {
|
|
|
260
276
|
}
|
|
261
277
|
}
|
|
262
278
|
|
|
263
|
-
module.exports = { WebAdapter, WEB_OWNER_ID };
|
|
279
|
+
module.exports = { WebAdapter, WEB_OWNER_ID, WEB_GUEST_ID };
|
package/core/access.js
CHANGED
|
@@ -14,6 +14,14 @@ const { atomicWriteFileSync, readJsonWithFallback, withFileLock } = require("./f
|
|
|
14
14
|
// owner is identified by the Kazee user id. Resolve via env at call time so
|
|
15
15
|
// /channel add can update the value without a process restart.
|
|
16
16
|
function transportOwnerUserId(transport) {
|
|
17
|
+
// A personal bot's dashboard and voice bridge are ways to reach its one
|
|
18
|
+
// owner (Central's), not owners of their own: they count only when they
|
|
19
|
+
// name her.
|
|
20
|
+
const personalOwner = personalBotOwner();
|
|
21
|
+
if (personalOwner !== null && (transport === "web" || transport === "voice")) {
|
|
22
|
+
if (transport === "web") return personalOwner;
|
|
23
|
+
return String(config.VOICE_OWNER_USER_ID || "") === personalOwner ? personalOwner : "";
|
|
24
|
+
}
|
|
17
25
|
if (transport === "kazee") return config.KAZEE_OWNER_USER_ID || "";
|
|
18
26
|
// Spaces actors are central/Kazee user ids, so the owner carries the same id
|
|
19
27
|
// there. Without this the owner's own task comments authorize as a stranger
|
|
@@ -29,6 +37,16 @@ function transportOwnerUserId(transport) {
|
|
|
29
37
|
return "";
|
|
30
38
|
}
|
|
31
39
|
|
|
40
|
+
// Central's owner of a personal bot ("" when not yet known), or null for an
|
|
41
|
+
// autonomous bot.
|
|
42
|
+
function personalBotOwner() {
|
|
43
|
+
try {
|
|
44
|
+
const botProfile = require("./bot-profile");
|
|
45
|
+
if (botProfile.isPersonal()) return botProfile.ownerUserId();
|
|
46
|
+
} catch (e) {}
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
|
|
32
50
|
function matchesTransportOwner() {
|
|
33
51
|
const transport = currentTransport();
|
|
34
52
|
const userId = currentUserId();
|
|
@@ -109,6 +127,13 @@ function isChatAuthorized(chatId, envelope) {
|
|
|
109
127
|
// member of that group to owner.
|
|
110
128
|
function isChatOwner(chatId) {
|
|
111
129
|
if (matchesTransportOwner()) return true;
|
|
130
|
+
// A personal bot has exactly one owner, the person in Central
|
|
131
|
+
// (core/bot-profile.js, applied to KAZEE_OWNER_USER_ID). The second-owner
|
|
132
|
+
// setup — people records, auth.json or chat ids marked owner — does not
|
|
133
|
+
// apply: admins look after the bot through Agent Space and Central.
|
|
134
|
+
try {
|
|
135
|
+
if (require("./bot-profile").isPersonal()) return false;
|
|
136
|
+
} catch (e) {}
|
|
112
137
|
const inGroup = currentIsGroup();
|
|
113
138
|
const speakerId = String(currentUserId() || "");
|
|
114
139
|
// In a group only the speaker counts; in a DM the passed chat id is the
|
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/app-pass.js
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// One pass helper for every app call (plan v2 "personal bots act for their
|
|
2
|
+
// owner", 25 Oct).
|
|
3
|
+
//
|
|
4
|
+
// A personal bot never hands its own key (KAZEE_BOT_TOKEN) to an app. For each
|
|
5
|
+
// app it trades the key for a 10-minute pass at Central (POST /api/bots/pass),
|
|
6
|
+
// reuses it until just before it runs out, then gets a new one. The pass acts
|
|
7
|
+
// as its owner: the app sees "Cynthia, via smart-cynthia" with the AI mark.
|
|
8
|
+
// An autonomous bot, or one whose Central has no bot:me yet, keeps using its
|
|
9
|
+
// own key, unchanged.
|
|
10
|
+
//
|
|
11
|
+
// A refused pass throws PassRefused with a plain message telling the owner how
|
|
12
|
+
// to switch it on; the owner hears it at most once a day per app and reason
|
|
13
|
+
// (bot.js sends it as a one-tap card, core/owner-setup.js).
|
|
14
|
+
//
|
|
15
|
+
// Parent process only, like the key itself (a CONFINED secret never passed to
|
|
16
|
+
// a provider or tool subprocess).
|
|
17
|
+
|
|
18
|
+
const botProfile = require("./bot-profile");
|
|
19
|
+
|
|
20
|
+
// Get a new pass this long before the old one runs out.
|
|
21
|
+
const RENEW_BEFORE_MS = 60 * 1000;
|
|
22
|
+
const TELL_OWNER_EVERY_MS = 24 * 60 * 60 * 1000;
|
|
23
|
+
|
|
24
|
+
// Central's Application.name -> what the owner calls it.
|
|
25
|
+
const APP_LABELS = {
|
|
26
|
+
"kazee-spaces-backend": "Spaces",
|
|
27
|
+
"kazee-crm": "CRM",
|
|
28
|
+
crm: "CRM",
|
|
29
|
+
Ticket_Central: "Tickets",
|
|
30
|
+
"agent-space-backend": "Agent Space",
|
|
31
|
+
"kazee-infrastructure": "Infrastructure",
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const passes = new Map(); // applicationName -> { token, expiresAt }
|
|
35
|
+
const inflight = new Map(); // applicationName -> Promise<string>
|
|
36
|
+
const told = new Map(); // `${app}:${reason}` -> last told at
|
|
37
|
+
let ownerNotifier = null;
|
|
38
|
+
|
|
39
|
+
class PassRefused extends Error {
|
|
40
|
+
constructor(applicationName, reason, ownerMessage, status) {
|
|
41
|
+
super(`Central refused a pass for ${applicationName}: ${reason}`);
|
|
42
|
+
this.applicationName = applicationName;
|
|
43
|
+
this.reason = reason;
|
|
44
|
+
this.ownerMessage = ownerMessage;
|
|
45
|
+
this.status = status;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function appLabel(applicationName) {
|
|
50
|
+
return APP_LABELS[applicationName] || applicationName;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// What the owner reads when Central refuses a pass.
|
|
54
|
+
function ownerHelp(reason, applicationName) {
|
|
55
|
+
const first = botProfile.ownerFirstName();
|
|
56
|
+
const app = appLabel(applicationName);
|
|
57
|
+
const where = botProfile.MY_ASSISTANTS;
|
|
58
|
+
switch (reason) {
|
|
59
|
+
case "consent-off":
|
|
60
|
+
return `${first}, my permission to act for you is switched off, so I can't work in ${app} for you. To switch it back on: ${where}, tap Let it act for me and tick ${app}.`;
|
|
61
|
+
case "consent-ended":
|
|
62
|
+
return `${first}, your permission for me to act for you has ended, so I can't work in ${app}. Renew it with one tap in ${where}.`;
|
|
63
|
+
case "app-not-ticked":
|
|
64
|
+
return `${first}, you haven't let me act for you in ${app}. To allow it: ${where}, and tick ${app}.`;
|
|
65
|
+
case "owner-no-access":
|
|
66
|
+
return `${first}, your own account can't use ${app} right now, so I can't either. Ask your admin for access.`;
|
|
67
|
+
case "owner-inactive":
|
|
68
|
+
return `I can't act in ${app}: Central says my owner's account is not active.`;
|
|
69
|
+
case "bot-disabled":
|
|
70
|
+
return `I can't act in ${app}: an admin has switched me off in Central.`;
|
|
71
|
+
case "consent-none":
|
|
72
|
+
default:
|
|
73
|
+
return `${first}, I can't work in ${app} for you yet. To let me act for you: open ${where}, tap Let it act for me and tick ${app}.`;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function setOwnerNotifier(fn) {
|
|
78
|
+
ownerNotifier = typeof fn === "function" ? fn : null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function tellOwnerOnce(err, now) {
|
|
82
|
+
if (!ownerNotifier) return;
|
|
83
|
+
const key = `${err.applicationName}:${err.reason}`;
|
|
84
|
+
const last = told.get(key) || 0;
|
|
85
|
+
if (now - last < TELL_OWNER_EVERY_MS) return;
|
|
86
|
+
told.set(key, now);
|
|
87
|
+
try { await ownerNotifier(err.ownerMessage, err); }
|
|
88
|
+
catch (e) { console.error("app-pass: could not tell the owner:", e.message); }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function fetchPass(applicationName, request, now) {
|
|
92
|
+
const token = botProfile.botToken();
|
|
93
|
+
if (!token) throw new Error("KAZEE_BOT_TOKEN unset");
|
|
94
|
+
const res = await request("POST", "/api/bots/pass", token, { applicationName });
|
|
95
|
+
const body = (res && res.body) || {};
|
|
96
|
+
if (res && (res.status === 200 || res.status === 201) && typeof body.pass === "string" && body.pass.startsWith("kzp_")) {
|
|
97
|
+
const expiresAt = Date.parse(body.expiresAt);
|
|
98
|
+
passes.set(applicationName, { token: body.pass, expiresAt: Number.isFinite(expiresAt) ? expiresAt : now() + 9 * 60 * 1000 });
|
|
99
|
+
return body.pass;
|
|
100
|
+
}
|
|
101
|
+
const reason = body.reason || (res && res.status === 401 ? "bot-key-invalid" : `http-${res && res.status}`);
|
|
102
|
+
const err = new PassRefused(applicationName, reason, ownerHelp(reason, applicationName), res && res.status);
|
|
103
|
+
await tellOwnerOnce(err, now());
|
|
104
|
+
throw err;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// The token to present at `applicationName`: a pass for a personal bot, the
|
|
108
|
+
// bot's own key otherwise.
|
|
109
|
+
async function tokenFor(applicationName, { request = botProfile.centralRequest, now = Date.now } = {}) {
|
|
110
|
+
if (!botProfile.isPersonal()) return botProfile.botToken();
|
|
111
|
+
const held = passes.get(applicationName);
|
|
112
|
+
if (held && held.expiresAt - now() > RENEW_BEFORE_MS) return held.token;
|
|
113
|
+
if (inflight.has(applicationName)) return inflight.get(applicationName);
|
|
114
|
+
const p = fetchPass(applicationName, request, now).finally(() => inflight.delete(applicationName));
|
|
115
|
+
inflight.set(applicationName, p);
|
|
116
|
+
return p;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// A token getter for clients that take one (channels/spaces/client.js).
|
|
120
|
+
function provider(applicationName, opts) {
|
|
121
|
+
return () => tokenFor(applicationName, opts);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Drop a held pass (an app answered 401 to it).
|
|
125
|
+
function invalidate(applicationName) {
|
|
126
|
+
passes.delete(applicationName);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function reset() {
|
|
130
|
+
passes.clear();
|
|
131
|
+
inflight.clear();
|
|
132
|
+
told.clear();
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
module.exports = {
|
|
136
|
+
tokenFor,
|
|
137
|
+
provider,
|
|
138
|
+
invalidate,
|
|
139
|
+
reset,
|
|
140
|
+
setOwnerNotifier,
|
|
141
|
+
ownerHelp,
|
|
142
|
+
appLabel,
|
|
143
|
+
PassRefused,
|
|
144
|
+
RENEW_BEFORE_MS,
|
|
145
|
+
};
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
// What kind of bot this is, from Central (plan v2 "personal bots act for their
|
|
2
|
+
// owner", 25 Oct).
|
|
3
|
+
//
|
|
4
|
+
// On start, and every 15 minutes, the bot asks Central with its own key
|
|
5
|
+
// (GET /api/bots/me): personal or autonomous, its one owner, and the owner's
|
|
6
|
+
// consent (the apps she ticked, the end date, renewDue a week before it ends).
|
|
7
|
+
// The answer is kept on disk so a restart while Central is down still knows.
|
|
8
|
+
//
|
|
9
|
+
// A personal bot:
|
|
10
|
+
// • has exactly one owner, the person in Central. applyOwner() puts her id in
|
|
11
|
+
// KAZEE_OWNER_USER_ID, and access.js counts no other owner (no second-owner
|
|
12
|
+
// records). Admins look after the bot through Agent Space and Central.
|
|
13
|
+
// • never hands its own key to an app: core/app-pass.js trades it for a pass.
|
|
14
|
+
// • gets its own instructions (instructionsBlock).
|
|
15
|
+
// An autonomous bot, or a bot whose Central has no bot:me yet, is unchanged.
|
|
16
|
+
|
|
17
|
+
const fs = require("fs");
|
|
18
|
+
const path = require("path");
|
|
19
|
+
const https = require("https");
|
|
20
|
+
const http = require("http");
|
|
21
|
+
const { URL } = require("url");
|
|
22
|
+
|
|
23
|
+
const FILE_NAME = "bot-profile.json";
|
|
24
|
+
const REFRESH_MS = 15 * 60 * 1000;
|
|
25
|
+
|
|
26
|
+
let profile;
|
|
27
|
+
let timer = null;
|
|
28
|
+
|
|
29
|
+
function configModule() { return require("./config"); }
|
|
30
|
+
|
|
31
|
+
function profileFile(dir) {
|
|
32
|
+
return path.join(dir || configModule().CONFIG_DIR, FILE_NAME);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function readProfile(dir) {
|
|
36
|
+
try {
|
|
37
|
+
const raw = JSON.parse(fs.readFileSync(profileFile(dir), "utf-8"));
|
|
38
|
+
return raw && raw.bot ? raw : null;
|
|
39
|
+
} catch (_) {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function get() {
|
|
45
|
+
if (profile === undefined) profile = readProfile();
|
|
46
|
+
return profile;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function isPersonal() { return get()?.bot?.botKind === "personal"; }
|
|
50
|
+
function ownerUserId() { return isPersonal() ? String(get()?.owner?.id || "") : ""; }
|
|
51
|
+
function ownerName() { return isPersonal() ? String(get()?.owner?.name || "") : ""; }
|
|
52
|
+
function ownerFirstName() { return ownerName().split(" ")[0] || "your owner"; }
|
|
53
|
+
function consent() { return isPersonal() ? get()?.consent || null : null; }
|
|
54
|
+
function consentApplications() {
|
|
55
|
+
const c = consent();
|
|
56
|
+
return c && c.live !== false ? (c.applications || []).map((a) => a.name).filter(Boolean) : [];
|
|
57
|
+
}
|
|
58
|
+
function renewDue() { return !!consent()?.renewDue; }
|
|
59
|
+
|
|
60
|
+
// The apps whose back ends take a pass, so the owner can tick them (Central's
|
|
61
|
+
// OWNER_PASS_APPLICATIONS). Central's list when bot:me sends one, else its
|
|
62
|
+
// default; Chat is left out, the bot talks there as itself.
|
|
63
|
+
const DEFAULT_PASS_APPLICATIONS = ["Ticket_Central", "kazee-crm", "agent-space-backend", "kazee-infrastructure", "kazee-spaces-backend"];
|
|
64
|
+
function passApplications() {
|
|
65
|
+
if (!isPersonal()) return [];
|
|
66
|
+
const p = get();
|
|
67
|
+
const list = Array.isArray(p?.passApplications) && p.passApplications.length ? p.passApplications : DEFAULT_PASS_APPLICATIONS;
|
|
68
|
+
const chat = p?.chatApplications || [];
|
|
69
|
+
return list.filter((a) => a && !chat.includes(a));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Read straight from disk with no config: config.js calls this while it loads,
|
|
73
|
+
// so the owner is right before any adapter is built.
|
|
74
|
+
function cachedPersonalOwnerId(dir) {
|
|
75
|
+
const p = readProfile(dir);
|
|
76
|
+
return p?.bot?.botKind === "personal" && p.owner?.id ? String(p.owner.id) : "";
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// A personal bot's one owner replaces the configured Kazee owner.
|
|
80
|
+
function applyOwner(config) {
|
|
81
|
+
const owner = ownerUserId();
|
|
82
|
+
if (owner && config) config.KAZEE_OWNER_USER_ID = owner;
|
|
83
|
+
return owner;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function centralUrl() {
|
|
87
|
+
const { config } = configModule();
|
|
88
|
+
return String(config.CENTRAL_URL || "https://central.inet.africa").replace(/\/$/, "");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function botToken() {
|
|
92
|
+
const { config } = configModule();
|
|
93
|
+
return config.KAZEE_BOT_TOKEN || process.env.KAZEE_BOT_TOKEN || "";
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// One JSON request to Central. Resolves { status, body } for any HTTP answer;
|
|
97
|
+
// rejects only when Central can't be reached.
|
|
98
|
+
function centralRequest(method, urlPath, token, body, timeoutMs = 15000) {
|
|
99
|
+
return new Promise((resolve, reject) => {
|
|
100
|
+
const u = new URL(centralUrl() + urlPath);
|
|
101
|
+
const lib = u.protocol === "https:" ? https : http;
|
|
102
|
+
const data = body != null ? Buffer.from(JSON.stringify(body)) : null;
|
|
103
|
+
const headers = { Authorization: `Bearer ${token}`, Accept: "application/json" };
|
|
104
|
+
if (data) {
|
|
105
|
+
headers["Content-Type"] = "application/json";
|
|
106
|
+
headers["Content-Length"] = data.length;
|
|
107
|
+
}
|
|
108
|
+
const req = lib.request(
|
|
109
|
+
{ method, hostname: u.hostname, port: u.port || (u.protocol === "https:" ? 443 : 80), path: u.pathname + (u.search || ""), headers },
|
|
110
|
+
(res) => {
|
|
111
|
+
let chunks = "";
|
|
112
|
+
res.on("data", (c) => { chunks += c; });
|
|
113
|
+
res.on("end", () => {
|
|
114
|
+
let parsed = {};
|
|
115
|
+
try { parsed = chunks ? JSON.parse(chunks) : {}; } catch (_) {}
|
|
116
|
+
resolve({ status: res.statusCode, body: parsed });
|
|
117
|
+
});
|
|
118
|
+
},
|
|
119
|
+
);
|
|
120
|
+
req.on("error", reject);
|
|
121
|
+
req.setTimeout(timeoutMs, () => req.destroy(new Error(`${method} ${urlPath} timeout`)));
|
|
122
|
+
if (data) req.write(data);
|
|
123
|
+
req.end();
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Ask Central what this bot is. An old Central (404) or a refusal leaves a
|
|
128
|
+
// bot that was never told as it was; Central down keeps the last answer.
|
|
129
|
+
async function refresh({ request = centralRequest } = {}) {
|
|
130
|
+
const token = botToken();
|
|
131
|
+
if (!token) return get();
|
|
132
|
+
let res;
|
|
133
|
+
try {
|
|
134
|
+
res = await request("GET", "/api/bots/me", token);
|
|
135
|
+
} catch (e) {
|
|
136
|
+
console.error("bot-profile: Central unreachable, keeping the last answer:", e.message);
|
|
137
|
+
return get();
|
|
138
|
+
}
|
|
139
|
+
if (res && res.status === 200 && res.body && res.body.bot) {
|
|
140
|
+
profile = {
|
|
141
|
+
bot: res.body.bot,
|
|
142
|
+
owner: res.body.owner || null,
|
|
143
|
+
consent: res.body.consent || null,
|
|
144
|
+
chatApplications: res.body.chatApplications || [],
|
|
145
|
+
...(Array.isArray(res.body.passApplications) ? { passApplications: res.body.passApplications } : {}),
|
|
146
|
+
checkedAt: new Date().toISOString(),
|
|
147
|
+
};
|
|
148
|
+
try {
|
|
149
|
+
const { atomicWriteFileSync } = require("./fsutil");
|
|
150
|
+
atomicWriteFileSync(profileFile(), JSON.stringify(profile, null, 2));
|
|
151
|
+
} catch (e) {
|
|
152
|
+
console.error("bot-profile: write failed:", e.message);
|
|
153
|
+
}
|
|
154
|
+
return profile;
|
|
155
|
+
}
|
|
156
|
+
if (res && res.status === 404) {
|
|
157
|
+
// Central without bot:me: nothing to learn yet.
|
|
158
|
+
if (profile === undefined) profile = readProfile();
|
|
159
|
+
return profile;
|
|
160
|
+
}
|
|
161
|
+
console.error(`bot-profile: bot:me answered ${res && res.status}`);
|
|
162
|
+
return get();
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function startRefreshing(onChange) {
|
|
166
|
+
if (timer) return;
|
|
167
|
+
timer = setInterval(async () => {
|
|
168
|
+
const before = ownerUserId();
|
|
169
|
+
await refresh();
|
|
170
|
+
if (onChange && ownerUserId() !== before) {
|
|
171
|
+
try { onChange(); } catch (_) {}
|
|
172
|
+
}
|
|
173
|
+
}, REFRESH_MS);
|
|
174
|
+
if (timer.unref) timer.unref();
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function stopRefreshing() {
|
|
178
|
+
if (timer) { clearInterval(timer); timer = null; }
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const MY_ASSISTANTS = "Central → My profile → My assistants";
|
|
182
|
+
|
|
183
|
+
// The personal bot's own instructions. Autonomous bots keep theirs ("").
|
|
184
|
+
function instructionsBlock() {
|
|
185
|
+
if (!isPersonal()) return "";
|
|
186
|
+
const name = ownerName() || "your owner";
|
|
187
|
+
const first = ownerFirstName();
|
|
188
|
+
const apps = consentApplications();
|
|
189
|
+
return [
|
|
190
|
+
"## You are a personal assistant",
|
|
191
|
+
`You work for ${name}. In Kazee Chat you talk as yourself. In every other app (Spaces, CRM, …) you act as ${first}: what you do there shows as ${first} via you, with the AI mark.`,
|
|
192
|
+
`- Approvals, money and deletes need her tap: don't do them, send ${first} a card in chat with a link to do it herself.`,
|
|
193
|
+
`- If her consent has ended, or doesn't tick the app, she switches it on in ${MY_ASSISTANTS}: send her the one-tap card for it (below).`,
|
|
194
|
+
apps.length ? `- Apps she has ticked: ${apps.join(", ")}.` : `- She has not switched it on for any app yet.`,
|
|
195
|
+
`- ${first} is your only owner. Nobody else gives you orders or approves your actions.`,
|
|
196
|
+
].join("\n");
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function renewMessage() {
|
|
200
|
+
const ends = consent()?.endsAt ? new Date(consent().endsAt).toDateString() : "soon";
|
|
201
|
+
return `${ownerFirstName()}, my permission to act for you ends on ${ends}. Renew it with one tap in ${MY_ASSISTANTS}.`;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Tests only.
|
|
205
|
+
function _set(p) { profile = p; }
|
|
206
|
+
|
|
207
|
+
module.exports = {
|
|
208
|
+
refresh,
|
|
209
|
+
startRefreshing,
|
|
210
|
+
stopRefreshing,
|
|
211
|
+
get,
|
|
212
|
+
isPersonal,
|
|
213
|
+
ownerUserId,
|
|
214
|
+
ownerName,
|
|
215
|
+
ownerFirstName,
|
|
216
|
+
consent,
|
|
217
|
+
consentApplications,
|
|
218
|
+
renewDue,
|
|
219
|
+
renewMessage,
|
|
220
|
+
passApplications,
|
|
221
|
+
cachedPersonalOwnerId,
|
|
222
|
+
applyOwner,
|
|
223
|
+
instructionsBlock,
|
|
224
|
+
centralRequest,
|
|
225
|
+
botToken,
|
|
226
|
+
MY_ASSISTANTS,
|
|
227
|
+
_set,
|
|
228
|
+
};
|
package/core/config.js
CHANGED
|
@@ -53,6 +53,13 @@ function settingValue(key) {
|
|
|
53
53
|
return fromFile === undefined || fromFile === "" ? undefined : fromFile;
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
// A personal bot has one owner: the person Central names (core/bot-profile.js,
|
|
57
|
+
// kept on disk from the last bot:me). Applied here, before any adapter is built.
|
|
58
|
+
try {
|
|
59
|
+
const centralOwner = require("./bot-profile").cachedPersonalOwnerId(CONFIG_DIR);
|
|
60
|
+
if (centralOwner) config.KAZEE_OWNER_USER_ID = centralOwner;
|
|
61
|
+
} catch (_) { /* no profile yet */ }
|
|
62
|
+
|
|
56
63
|
function hasPathSeparator(value) {
|
|
57
64
|
return value.includes("/") || value.includes("\\");
|
|
58
65
|
}
|