@inetafrica/open-claudia 3.5.74 → 3.5.75

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 CHANGED
@@ -1,5 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## 3.5.75
4
+
5
+ 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.
6
+
7
+ - **Type check** (`core/bot-profile.js`): on start, and every 15 minutes, the bot asks Central `GET /api/bots/me` with its own key for its kind, its one owner and the owner's consent (ticked apps, end date, `renewDue`). The answer is kept in `bot-profile.json`, so a restart while Central is down still knows.
8
+ - **One owner**: a personal bot's owner is the person in Central. Her id replaces `KAZEE_OWNER_USER_ID`, from config load and on every refresh. `access.isChatOwner` counts no other owner (no people records, `auth.json` or chat ids marked owner), and the web dashboard and the voice bridge reach her rather than being owners themselves.
9
+ - **One pass helper** (`core/app-pass.js`): every app call of a personal bot trades its key for a 10-minute pass for that app (`POST /api/bots/pass`), reused until a minute before it ends, one request per app at a time; the bot's own key is never sent to an app. Kazee Chat keeps the bot's own key. The owner reads each app's own name (Spaces, CRM, Tickets, Agent Space, Infrastructure) for Central's exact app names.
10
+ - **Spaces**: the conversational channel stays idle for a personal bot (no socket of its own); anything still sent through it, and the connected-apps plane watching the apps the owner ticked, goes out with her pass.
11
+ - **One-tap card** (SEC-28, `core/owner-setup.js`): when an app refuses a pass because she has not switched the bot on there, and a week before her consent ends, the owner gets a Kazee Chat card with one button that opens Central at the form (Switch me on / Renew); at most once a day per app and reason. `open-claudia owner-setup [--apps a,b] [--renew]` sends it on request; `open-claudia pass <app>` prints a fresh pass, never the key.
12
+ - **Instructions**: a personal bot gets its own block in the core instructions (who it works for, approvals, money and deletes need her tap, send the one-tap card).
13
+
14
+ Tests: `test-bot-acts-for-owner.js` (in the suite).
15
+
3
16
  ## 3.5.74
4
17
 
5
18
  3.5.73 plus the Rust command helper, released on Sumeet's card of 8 Nov ("Yes, Maya Cloud first, then one bot at a time") after 3 clean days on his Mac (about 62,000 commands, no crashes).
package/bin/cli.js CHANGED
@@ -218,6 +218,41 @@ switch (command) {
218
218
  break;
219
219
  }
220
220
 
221
+ case "pass": {
222
+ // A fresh pass for one app (core/app-pass.js), for a tool that calls an
223
+ // app for a personal bot's owner: e.g. `open-claudia pass kazee-spaces-backend`.
224
+ // Never prints the bot's own key: an autonomous bot has no pass to give.
225
+ const applicationName = args[1];
226
+ if (!applicationName) {
227
+ console.error("Usage: open-claudia pass <Central application name, e.g. kazee-spaces-backend>");
228
+ process.exit(2);
229
+ }
230
+ (async () => {
231
+ const botProfile = require(path.join(botDir, "core/bot-profile"));
232
+ const appPass = require(path.join(botDir, "core/app-pass"));
233
+ await botProfile.refresh();
234
+ if (!botProfile.isPersonal()) {
235
+ console.error("Only a personal bot uses passes; this bot calls apps with its own key.");
236
+ process.exit(2);
237
+ }
238
+ try {
239
+ process.stdout.write(await appPass.tokenFor(applicationName));
240
+ process.stdout.write("\n");
241
+ process.exit(0);
242
+ } catch (e) {
243
+ console.error(e instanceof appPass.PassRefused ? e.ownerMessage : e.message);
244
+ process.exit(1);
245
+ }
246
+ })();
247
+ break;
248
+ }
249
+
250
+ case "owner-setup": {
251
+ // The one-tap card for a personal bot's owner (core/owner-setup.js).
252
+ require("./owner-setup").run(args.slice(1)).then((code) => process.exit(code));
253
+ break;
254
+ }
255
+
221
256
  case "health": {
222
257
  console.log("Running health checks...\n");
223
258
  const verbose = args.includes("-v") || args.includes("--verbose");
@@ -679,6 +714,12 @@ Background work (only inside an active bot-spawned task):
679
714
  Cluster control (self-management of this bot's own deployment via the broker):
680
715
  open-claudia cluster status|logs|restart|start|stop|scale <0|1>|sync
681
716
 
717
+ Personal bots (acting for their owner):
718
+ open-claudia pass <app> A fresh 10-minute pass to act as your owner at one app
719
+ (e.g. kazee-spaces-backend); never your own key
720
+ open-claudia owner-setup [--apps a,b] [--renew] Send your owner a one-tap card in Kazee Chat to switch
721
+ you on (default: apps not switched on yet) or renew
722
+
682
723
  Multi-user / cross-channel:
683
724
  open-claudia people list|show|add|note|link Roster of known team members
684
725
  open-claudia intros list|approve|reject Pending introductions from unknown chats
@@ -0,0 +1,56 @@
1
+ // open-claudia owner-setup — send a personal bot's owner the one-tap card that opens
2
+ // Central at the form to switch the bot on (or renew it). See
3
+ // core/owner-setup.js. The running bot sends it to the owner's direct Kazee chat.
4
+
5
+ const path = require("path");
6
+
7
+ const HELP = `
8
+ Send your owner a one-tap card to switch you on, or to renew.
9
+
10
+ open-claudia owner-setup the apps you can work in that your owner hasn't switched on yet
11
+ open-claudia owner-setup --apps Tickets just these apps (Central names or Tickets, CRM, Spaces...)
12
+ open-claudia owner-setup --renew renew before your owner's permission ends
13
+
14
+ Personal bots only. You can never switch yourself on: only your owner can confirm.
15
+ `;
16
+
17
+ function takeFlag(args, name) {
18
+ const idx = args.indexOf(`--${name}`);
19
+ if (idx < 0) return null;
20
+ const v = args[idx + 1];
21
+ args.splice(idx, 2);
22
+ return v;
23
+ }
24
+
25
+ // Resolves the exit code. deps (tests): refresh, post, log, error.
26
+ async function run(args, deps = {}) {
27
+ const log = deps.log || console.log;
28
+ const error = deps.error || console.error;
29
+ args = [...args];
30
+ if (args[0] === "--help" || args[0] === "help") { log(HELP); return 0; }
31
+ const botProfile = require(path.join(__dirname, "..", "core", "bot-profile"));
32
+ const ownerSetup = require(path.join(__dirname, "..", "core", "owner-setup"));
33
+ const renew = args.includes("--renew");
34
+ const apps = String(takeFlag(args, "apps") || "").split(",").map((s) => s.trim()).filter(Boolean);
35
+ try {
36
+ await (deps.refresh || botProfile.refresh)();
37
+ } catch (_) { /* the last answer on disk */ }
38
+ const planned = ownerSetup.plan({ apps, renew });
39
+ if (planned.refused) { error(planned.refused); return 2; }
40
+ if (planned.nothing) { log(planned.nothing); return 0; }
41
+ try {
42
+ const post = deps.post || require("./loopback-client").postJson;
43
+ const res = await post("owner-setup", { apps, renew });
44
+ if (res.nothing) { log(res.nothing); return 0; }
45
+ const who = botProfile.ownerFirstName();
46
+ if (res.sent === "already-sent") log(`${who} already got this card in the last day; not sent again.`);
47
+ else if (res.sent === "card") log(`Sent ${who} a card in Kazee Chat: "${res.title}".`);
48
+ else log(`The card could not be sent, so ${who} got the text with the link instead.`);
49
+ return 0;
50
+ } catch (e) {
51
+ error(`owner-setup: ${e.message}`);
52
+ return 1;
53
+ }
54
+ }
55
+
56
+ module.exports = { run, HELP };
package/bot.js CHANGED
@@ -264,6 +264,42 @@ setInterval(checkForUpdates, 5 * 60 * 1000);
264
264
 
265
265
  // ── Boot ────────────────────────────────────────────────────────────
266
266
 
267
+ // Tell a personal bot's owner in her Kazee DM that an app refused a pass: a
268
+ // one-tap card to switch the bot on there, else the text (core/owner-setup.js).
269
+ // Best effort: no Kazee adapter or DM means no message.
270
+ async function tellPersonalOwner(text, err) {
271
+ const ownerSetup = require("./core/owner-setup");
272
+ const kazee = adapters.find((a) => a.type === "kazee");
273
+ if (kazee) await ownerSetup.tellOwner(ownerSetup.forRefusal(err, text), { adapter: kazee });
274
+ }
275
+
276
+ // A week before the consent ends: the renew card, once a day.
277
+ async function maybeSendRenewNotice() {
278
+ const kazee = adapters.find((a) => a.type === "kazee");
279
+ if (kazee) await require("./core/owner-setup").maybeSendRenew({ adapter: kazee });
280
+ }
281
+
282
+ async function startBotProfile() {
283
+ const botProfile = require("./core/bot-profile");
284
+ const applyOwner = () => {
285
+ const owner = botProfile.applyOwner(config);
286
+ if (!owner) return;
287
+ for (const a of adapters) {
288
+ if (a.type === "kazee" || a.type === "spaces") a.ownerUserId = owner;
289
+ }
290
+ };
291
+ await botProfile.refresh();
292
+ applyOwner();
293
+ console.log(botProfile.isPersonal()
294
+ ? `Bot profile: personal, owner ${botProfile.ownerName() || botProfile.ownerUserId()}; apps ticked: ${botProfile.consentApplications().join(", ") || "none"}`
295
+ : `Bot profile: ${botProfile.get()?.bot?.botKind || "unknown (Central has no bot:me)"}`);
296
+ require("./core/app-pass").setOwnerNotifier(tellPersonalOwner);
297
+ botProfile.startRefreshing(applyOwner);
298
+ const renewTimer = setInterval(() => { maybeSendRenewNotice(); }, 60 * 60 * 1000);
299
+ if (renewTimer.unref) renewTimer.unref();
300
+ setTimeout(() => { maybeSendRenewNotice(); }, 60 * 1000).unref?.();
301
+ }
302
+
267
303
  // Minimal boot: adapters + slash commands only. No scheduler/crons, no dream,
268
304
  // no connected-apps, no loopback, no web UI, no onboarding — nothing that can
269
305
  // run an agent turn or write memory. The alert tells the owner exactly why and
@@ -332,6 +368,12 @@ async function safeModeBoot() {
332
368
  }
333
369
  } catch (e) {}
334
370
 
371
+ // Personal or autonomous, and who the one owner is: asked of Central before
372
+ // the adapters start (core/bot-profile.js). A personal bot's app calls go
373
+ // through passes for that owner (core/app-pass.js).
374
+ try { await startBotProfile(); }
375
+ catch (e) { console.error("bot profile init failed:", e.message); }
376
+
335
377
  initScheduler(adapters);
336
378
  try { require("./core/dream").initDream(adapters); }
337
379
  catch (e) { console.error("dream init failed:", e.message); }
@@ -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");
@@ -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
@@ -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
+ };