@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.
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
 
3
- const { MediaStreamTrack, RTCPeerConnection, RTCRtpHeaderExtensionParameters, RtpHeader, RtpPacket } = require("werift");
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
- this.client = new SpacesClient({ baseUrl: this.url, token });
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
- _get(urlPath, timeoutMs) { return request(this.baseUrl, this.token, "GET", urlPath, null, timeoutMs); }
231
- _post(urlPath, body, timeoutMs) { return request(this.baseUrl, this.token, "POST", urlPath, body, timeoutMs); }
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
@@ -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: this.channelId,
76
- canonicalUserId: canonicalForChannel("web", this.channelId),
77
- userId: this.ownerUserId,
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: this.ownerUserId, name: "Owner", username: "" },
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: this.channelId,
94
- canonicalUserId: canonicalForChannel("web", this.channelId),
95
- userId: this.ownerUserId,
109
+ channelId: speaker,
110
+ canonicalUserId: canonicalForChannel("web", speaker),
111
+ userId: speaker,
96
112
  type: "action",
97
113
  messageId,
98
- from: { id: this.ownerUserId, name: "Owner", username: "" },
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.
@@ -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
  }