@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.
- package/CHANGELOG.md +17 -9
- package/Dockerfile +0 -2
- package/bin/cli.js +41 -0
- package/bin/owner-setup.js +56 -0
- package/bot.js +42 -0
- package/channels/kazee/call/camera.js +2 -1
- package/channels/kazee/call/session.js +5 -6
- package/channels/spaces/adapter.js +25 -1
- package/channels/spaces/client.js +6 -2
- package/channels/telegram/adapter.js +1 -2
- package/channels/web/adapter.js +27 -11
- package/core/access.js +25 -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 +1 -2
- package/core/loopback.js +21 -0
- package/core/owner-setup.js +216 -0
- package/core/pack-review.js +10 -77
- package/core/recall/index.js +2 -3
- package/core/recall/warm-walker.js +1 -31
- package/core/runner.js +1 -4
- package/core/system-prompt.js +20 -0
- package/core/usage-log.js +0 -1
- package/core/web-otp.js +17 -8
- package/core/web-sessions.js +11 -2
- package/docker-entrypoint.sh +0 -10
- package/package.json +2 -2
- package/test-provider-language.js +4 -3
- package/web.js +19 -8
|
@@ -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
|
}
|
package/core/connected-apps.js
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
};
|