@inetafrica/open-claudia 3.5.74-usage.2 → 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.
@@ -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
  }
@@ -96,14 +96,28 @@ function getJson(baseUrl, urlPath, token) {
96
96
  }
97
97
 
98
98
  // Ask central which apps this bot token is a member of. Returns [] on failure
99
- // (caller keeps the last-known list).
100
- async function fetchConnectedApps() {
99
+ // (caller keeps the last-known list). A personal bot watches the apps its owner
100
+ // ticked in her consent (core/bot-profile.js), as her: its own key only works
101
+ // in Kazee Chat once its key limit is on.
102
+ async function fetchConnectedApps({ request } = {}) {
103
+ const botProfile = require("./bot-profile");
104
+ if (botProfile.isPersonal()) {
105
+ return botProfile.consentApplications().map((name) => ({ id: name, name }));
106
+ }
101
107
  const token = botToken();
102
108
  if (!token) return [];
103
- const res = await getJson(CENTRAL_URL, "/api/principal/applications", token);
109
+ const res = request
110
+ ? (await request("GET", "/api/principal/applications", token)).body
111
+ : await getJson(CENTRAL_URL, "/api/principal/applications", token);
104
112
  return Array.isArray(res?.applications) ? res.applications : [];
105
113
  }
106
114
 
115
+ // The token to poll an app with: a pass for a personal bot (core/app-pass.js),
116
+ // the bot's own key otherwise.
117
+ async function tokenForApp(app, opts) {
118
+ return require("./app-pass").tokenFor(String(app?.name || ""), opts);
119
+ }
120
+
107
121
  function registerConnector(connector) {
108
122
  if (!connector || !connector.appKey) throw new Error("connector needs an appKey");
109
123
  connectors.set(connector.appKey, connector);
@@ -141,7 +155,7 @@ async function pollApp(app) {
141
155
  const connector = connectorFor(app);
142
156
  if (!connector) { state.status = "no-connector"; return; }
143
157
  try {
144
- const items = await connector.poll(app, botToken());
158
+ const items = await connector.poll(app, await tokenForApp(app));
145
159
  const added = recordItems(state, items || []);
146
160
  state.lastPollAt = now;
147
161
  state.status = "ok";
@@ -244,6 +258,7 @@ module.exports = {
244
258
  stopConnectedApps,
245
259
  registerConnector,
246
260
  fetchConnectedApps,
261
+ tokenForApp,
247
262
  snapshot,
248
263
  getAllInboxes,
249
264
  STORE_FILE,
package/core/handlers.js CHANGED
@@ -642,7 +642,9 @@ register({
642
642
  handler: async (env) => {
643
643
  if (!ownerEnv(env)) return;
644
644
  const { mint, TTL_MS } = require("./web-otp");
645
- const { token } = mint();
645
+ // The link names who asked, so a personal bot's dashboard knows it is
646
+ // her (core/access.js); an autonomous bot ignores it.
647
+ const { token } = mint({ userId: env.userId || "" });
646
648
  const url = `${dashboardBaseUrl()}/otp/${token}`;
647
649
  const mins = Math.round(TTL_MS / 60000);
648
650
  const lines = [`Web dashboard login (valid ${mins} min, single use):`, url];
package/core/identity.js CHANGED
@@ -98,6 +98,20 @@ function isConfiguredOwnerChannel(transport, channelId) {
98
98
  // is the same configured Kazee owner id — recognise them on Spaces too, else
99
99
  // the owner's own comments are classed external (default-deny).
100
100
  if (t === "spaces") return !!cfg.config.KAZEE_OWNER_USER_ID && c === String(cfg.config.KAZEE_OWNER_USER_ID);
101
+ // A personal bot's dashboard and voice bridge are owner channels only when
102
+ // they name its one owner (Central's); see access.transportOwnerUserId.
103
+ if (t === "voice" || t === "web") {
104
+ let personal = false, owner = "";
105
+ try {
106
+ const botProfile = require("./bot-profile");
107
+ personal = botProfile.isPersonal();
108
+ owner = botProfile.ownerUserId();
109
+ } catch (e) {}
110
+ if (personal) {
111
+ if (!owner || c !== owner) return false;
112
+ return t === "web" || String(cfg.config.VOICE_OWNER_USER_ID || "") === owner;
113
+ }
114
+ }
101
115
  if (t === "voice") return c === String(cfg.config.VOICE_OWNER_USER_ID || "voice-owner");
102
116
  // Web dashboard chat: fixed single-owner channel gated by the dashboard
103
117
  // session, so its identity is operator-implied rather than env-declared.
package/core/lessons.js CHANGED
@@ -184,8 +184,7 @@ function topicLessons(dir, { exclude = null, max = 3, maxChars = 900 } = {}) {
184
184
  const lessons = readLessons().filter((l) => l.src === dir);
185
185
  if (lessons.length === 0) return [];
186
186
  const skip = exclude || new Set(injectedLessons().map((l) => l.id));
187
- // The full list: reconciling just this pack would drop every other lesson's meta.
188
- const meta = reconcileMeta();
187
+ const meta = reconcileMeta(lessons);
189
188
  const ranked = lessons.filter((l) => !skip.has(l.id)).sort((a, b) => {
190
189
  const ma = meta[a.id] || {}, mb = meta[b.id] || {};
191
190
  return (mb.reinforced || 0) - (ma.reinforced || 0)
package/core/loopback.js CHANGED
@@ -108,6 +108,7 @@ const JSON_KINDS = new Set([
108
108
  "tool-gate",
109
109
  "say", "call-say", "call", "tts", "transcribe",
110
110
  "card-send", "card-update", "card-show", "decide-add", "decide-list", "decide-cancel",
111
+ "owner-setup",
111
112
  ]);
112
113
 
113
114
  // Who is calling, decided by the bot: the principal of a LIVE run whose
@@ -452,6 +453,26 @@ async function handleJson(req, res, url, kind) {
452
453
  return handleCardKind(res, kind, payload, { channelId, adapterId, adapter });
453
454
  }
454
455
 
456
+ // `open-claudia owner-setup` (core/owner-setup.js): the one-tap card that
457
+ // lets a personal bot's owner switch it on or renew it. It always goes to
458
+ // HER direct chat, never this room; the bot decides what it says from its
459
+ // own profile. A run that is not hers sends each card at most once a day.
460
+ if (kind === "owner-setup") {
461
+ const caller = callerOf(payload);
462
+ if (!caller.attributed) return reply(res, 403, { error: "owner-setup can only be used from a live, attributed run" });
463
+ const ownerSetup = require("./owner-setup");
464
+ const planned = ownerSetup.plan({ apps: Array.isArray(payload.apps) ? payload.apps.map(String) : [], renew: payload.renew === true });
465
+ if (planned.refused) return reply(res, 400, { error: planned.refused });
466
+ if (planned.nothing) return reply(res, 200, { ok: true, sent: null, nothing: planned.nothing });
467
+ try {
468
+ const sent = await ownerSetup.tellOwnerAsked(planned, { owner: caller.owner === true });
469
+ if (!sent) return reply(res, 502, { error: "could not reach the owner's direct chat in Kazee" });
470
+ return reply(res, 200, { ok: true, sent, title: planned.card ? planned.card.title : null });
471
+ } catch (e) {
472
+ return reply(res, 502, { error: e.message });
473
+ }
474
+ }
475
+
455
476
  if (kind === "call-say") {
456
477
  try {
457
478
  const result = await require("./call-speech").speak({ ...payload, channelId, userId, adapter: adapterId });
@@ -0,0 +1,216 @@
1
+ // The owner switches a personal bot on (or renews it) with one tap (SEC-28,
2
+ // Sumeet 30 Oct 2026).
3
+ //
4
+ // Instead of telling her where the page is, the bot sends its owner a Kazee
5
+ // Chat card with one link button that opens Central straight at the form; she
6
+ // only taps Confirm or Renew. Used when an app refuses a pass because she has
7
+ // not switched the bot on there (core/app-pass.js), for the daily renew notice
8
+ // a week before the consent ends, and for `open-claudia owner-setup`.
9
+ //
10
+ // The card is a "report" card (no questions) with a `link` block, at most 2 per
11
+ // card (chat-central, 30 Oct). If the card can't be sent the owner gets the old
12
+ // text, with the link.
13
+ //
14
+ // The bot can never switch itself on: the link only opens the form, and only
15
+ // the owner, signed in to Central, can confirm it.
16
+
17
+ const botProfile = require("./bot-profile");
18
+ const appPass = require("./app-pass");
19
+
20
+ const CENTRAL_WEB = "https://central.kazee.africa";
21
+ const ASSISTANTS_PATH = "/settings/assistants";
22
+ const LINK_LABEL_MAX = 40;
23
+ const CONSENT_DAYS = 90;
24
+ const SEND_EVERY_MS = 24 * 60 * 60 * 1000;
25
+
26
+ // The refusals the owner fixes herself by switching the bot on for the app.
27
+ const SWITCH_ON_REASONS = new Set(["consent-none", "consent-off", "consent-ended", "app-not-ticked"]);
28
+
29
+ const sentAt = new Map(); // card key -> last sent at (non-owner callers only)
30
+ let lastRenewDay = "";
31
+
32
+ function switchOnUrl(botId, apps) {
33
+ const list = apps.map((a) => encodeURIComponent(a)).join(",");
34
+ return `${CENTRAL_WEB}${ASSISTANTS_PATH}?bot=${encodeURIComponent(botId)}&do=switch-on&apps=${list}`;
35
+ }
36
+
37
+ function renewUrl(botId) {
38
+ return `${CENTRAL_WEB}${ASSISTANTS_PATH}?bot=${encodeURIComponent(botId)}&do=renew`;
39
+ }
40
+
41
+ // "Tickets", "Tickets and CRM", "Tickets, CRM and Spaces".
42
+ function listWords(words) {
43
+ if (words.length <= 1) return words.join("");
44
+ return `${words.slice(0, -1).join(", ")} and ${words[words.length - 1]}`;
45
+ }
46
+
47
+ function dateWords(when) {
48
+ const d = new Date(when);
49
+ if (!Number.isFinite(d.getTime())) return "soon";
50
+ return d.toLocaleDateString("en-GB", { weekday: "long", day: "numeric", month: "long", year: "numeric" });
51
+ }
52
+
53
+ // The bot as the owner knows it: its own name (BOT_NAME, else its hostname),
54
+ // else Central's name for it; and Central's id.
55
+ function botForCard() {
56
+ let name = "";
57
+ try { name = String(require("./config").settingValue("BOT_NAME") || "").trim(); } catch (_) {}
58
+ // Else the pod's name from its public hostname (as the dashboard does).
59
+ if (!name) {
60
+ try {
61
+ const url = String(require("./config").settingValue("WEB_PUBLIC_URL") || "").trim();
62
+ if (url) name = new URL(url).hostname.split(".")[0] || "";
63
+ } catch (_) {}
64
+ }
65
+ if (!name) name = botProfile.get()?.bot?.name || "your assistant";
66
+ return { id: String(botProfile.get()?.bot?.id || ""), name: name.charAt(0).toUpperCase() + name.slice(1) };
67
+ }
68
+
69
+ function linkBlock(label, url) {
70
+ return { type: "link", label: String(label).slice(0, LINK_LABEL_MAX), url };
71
+ }
72
+
73
+ function buildSwitchOnCard({ bot, apps }) {
74
+ const labels = apps.map(appPass.appLabel);
75
+ const where = labels.length <= 2 ? listWords(labels) : "your apps";
76
+ return {
77
+ v: 1,
78
+ kind: "report",
79
+ title: `Let ${bot.name} work in ${where} for you`,
80
+ body: `${bot.name} can then work in ${listWords(labels)} as you, with the AI mark, for ${CONSENT_DAYS} days, and you can switch it off any time.`,
81
+ blocks: [linkBlock("Switch me on", switchOnUrl(bot.id, apps))],
82
+ };
83
+ }
84
+
85
+ function buildRenewCard({ bot, endsAt }) {
86
+ return {
87
+ v: 1,
88
+ kind: "report",
89
+ title: `Keep ${bot.name} working for you`,
90
+ body: `${bot.name}'s permission to act for you ends on ${dateWords(endsAt)}; renew it for another ${CONSENT_DAYS} days.`,
91
+ blocks: [linkBlock("Renew", renewUrl(bot.id))],
92
+ };
93
+ }
94
+
95
+ // The text an old app (or a failed card) shows: the words plus the link.
96
+ function withLink(text, card) {
97
+ const url = card?.blocks?.find((b) => b.type === "link")?.url;
98
+ return url ? `${text}\n${url}` : text;
99
+ }
100
+
101
+ function switchOnText(apps) {
102
+ const words = listWords(apps.map(appPass.appLabel));
103
+ return `${botProfile.ownerFirstName()}, to let me work in ${words} for you: open ${botProfile.MY_ASSISTANTS}, tap Let it act for me and tick ${words}.`;
104
+ }
105
+
106
+ // A refused pass: the switch-on card for that app, or (another reason, like
107
+ // the owner's own account lacking access) just the text.
108
+ function forRefusal(err, text) {
109
+ const message = text || err?.ownerMessage || "";
110
+ if (!err || !SWITCH_ON_REASONS.has(err.reason) || !botProfile.isPersonal()) return { text: message };
111
+ const card = buildSwitchOnCard({ bot: botForCard(), apps: [err.applicationName] });
112
+ return { card, text: withLink(message, card), key: `switch-on:${err.applicationName}` };
113
+ }
114
+
115
+ function forRenew() {
116
+ const card = buildRenewCard({ bot: botForCard(), endsAt: botProfile.consent()?.endsAt });
117
+ return { card, text: withLink(botProfile.renewMessage(), card), key: "renew" };
118
+ }
119
+
120
+ // What `open-claudia owner-setup` sends. `apps` names Central apps (or the
121
+ // labels the owner uses: "Tickets"); none means the pass-ready apps the live
122
+ // consent doesn't tick yet. Answers { card, text } or { nothing: "<line>" }.
123
+ function plan({ apps = [], renew = false } = {}) {
124
+ if (!botProfile.isPersonal()) {
125
+ return { refused: "Only a personal bot is switched on by its owner; this bot works with its own key." };
126
+ }
127
+ const consent = botProfile.consent();
128
+ if (renew && consent?.endsAt) return forRenew();
129
+ const wanted = apps.map(appName).filter(Boolean);
130
+ const on = botProfile.consentApplications();
131
+ const missing = wanted.length ? wanted : botProfile.passApplications().filter((a) => !on.includes(a));
132
+ if (missing.length) {
133
+ const card = buildSwitchOnCard({ bot: botForCard(), apps: missing });
134
+ return { card, text: withLink(switchOnText(missing), card), key: `switch-on:${missing.join(",")}` };
135
+ }
136
+ if (botProfile.renewDue()) return forRenew();
137
+ const until = consent?.endsAt ? `, until ${dateWords(consent.endsAt)}` : "";
138
+ return { nothing: `Already switched on for every app I can work in for ${botProfile.ownerFirstName()} (${listWords(on.map(appPass.appLabel))})${until}. Nothing sent.` };
139
+ }
140
+
141
+ // "Tickets" -> "Ticket_Central"; a Central name stays as it is.
142
+ function appName(raw) {
143
+ const s = String(raw || "").trim();
144
+ if (!s) return "";
145
+ const known = [...new Set([...botProfile.passApplications(), ...botProfile.consentApplications()])];
146
+ return known.find((n) => n === s)
147
+ || known.find((n) => appPass.appLabel(n).toLowerCase() === s.toLowerCase())
148
+ || s;
149
+ }
150
+
151
+ function kazeeAdapter() {
152
+ try { return require("./adapter-registry").getAdapters().find((a) => a.type === "kazee") || null; }
153
+ catch (_) { return null; }
154
+ }
155
+
156
+ // Send { card, text } to the owner's direct Kazee chat: the card, else the
157
+ // text. Resolves "card", "text" or null (no Kazee, no owner, nothing sent).
158
+ async function tellOwner({ card, text }, { adapter = kazeeAdapter() } = {}) {
159
+ const ownerId = botProfile.ownerUserId();
160
+ if (!ownerId || !adapter || typeof adapter.directChatFor !== "function") return null;
161
+ const chatId = await adapter.directChatFor(ownerId);
162
+ if (!chatId) return null;
163
+ if (card && typeof adapter.sendCard === "function") {
164
+ try {
165
+ if (await adapter.sendCard(chatId, card, { content: text })) return "card";
166
+ console.error("owner-setup: Kazee Chat returned no message id for the card; sending the text");
167
+ } catch (e) {
168
+ console.error("owner-setup: card not sent, sending the text:", e.message);
169
+ }
170
+ }
171
+ if (!text) return null;
172
+ await adapter.send(chatId, text);
173
+ return "text";
174
+ }
175
+
176
+ // A run that is not the owner's asks at most once a day per card, so nobody
177
+ // else can fill her chat with cards.
178
+ async function tellOwnerAsked(sending, { owner = false, now = Date.now(), adapter } = {}) {
179
+ const key = sending.key || sending.text;
180
+ if (!owner && now - (sentAt.get(key) || 0) < SEND_EVERY_MS) return "already-sent";
181
+ sentAt.set(key, now);
182
+ return tellOwner(sending, adapter ? { adapter } : undefined);
183
+ }
184
+
185
+ // The renew card, once a day while Central says renewDue.
186
+ async function maybeSendRenew({ now = new Date(), adapter } = {}) {
187
+ const today = now.toISOString().slice(0, 10);
188
+ if (!botProfile.renewDue() || lastRenewDay === today) return null;
189
+ lastRenewDay = today;
190
+ try { return await tellOwner(forRenew(), adapter ? { adapter } : undefined); }
191
+ catch (e) { console.error("renew notice failed:", e.message); return null; }
192
+ }
193
+
194
+ // Tests only.
195
+ function reset() {
196
+ sentAt.clear();
197
+ lastRenewDay = "";
198
+ }
199
+
200
+ module.exports = {
201
+ buildSwitchOnCard,
202
+ buildRenewCard,
203
+ switchOnUrl,
204
+ renewUrl,
205
+ forRefusal,
206
+ forRenew,
207
+ plan,
208
+ tellOwner,
209
+ tellOwnerAsked,
210
+ maybeSendRenew,
211
+ botForCard,
212
+ reset,
213
+ SWITCH_ON_REASONS,
214
+ LINK_LABEL_MAX,
215
+ CENTRAL_WEB,
216
+ };