@inetafrica/open-claudia 3.0.15 → 3.0.17
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 +13 -0
- package/bot.js +12 -0
- package/channels/spaces/adapter.js +238 -0
- package/channels/spaces/approval-mirror.js +52 -0
- package/channels/spaces/client.js +183 -0
- package/channels/spaces/socket.js +40 -0
- package/channels/spaces/state.js +178 -0
- package/core/access.js +10 -0
- package/core/actions.js +8 -0
- package/core/adapter-registry.js +2 -0
- package/core/config.js +48 -0
- package/core/connected-apps.js +235 -0
- package/core/connectors/spaces.js +41 -0
- package/core/handlers.js +49 -0
- package/core/loopback.js +35 -4
- package/core/system-prompt.js +36 -0
- package/package.json +2 -2
- package/test-provider-language.js +7 -5
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// Persistent per-task cursor + processed-notification set for the Spaces
|
|
2
|
+
// conversational plane.
|
|
3
|
+
//
|
|
4
|
+
// The adapter's in-memory _seen set only dedups within a single process life.
|
|
5
|
+
// This store survives restarts so a notification a previous process already
|
|
6
|
+
// worked (redelivered on a socket reconnect or a REST catch-up after a bounce)
|
|
7
|
+
// is not re-handled. It also keeps a per-task cursor — where the bot last
|
|
8
|
+
// engaged on each task — for the advisory task loop and on-demand status.
|
|
9
|
+
//
|
|
10
|
+
// Parent-process only. Small, capped, atomic writes.
|
|
11
|
+
|
|
12
|
+
const path = require("path");
|
|
13
|
+
const { CONFIG_DIR } = require("../../core/config");
|
|
14
|
+
const { atomicWriteFileSync, readJsonWithFallback } = require("../../core/fsutil");
|
|
15
|
+
|
|
16
|
+
const STATE_FILE = path.join(CONFIG_DIR, "spaces-state.json");
|
|
17
|
+
const PROCESSED_CAP = 500;
|
|
18
|
+
|
|
19
|
+
let store = null;
|
|
20
|
+
|
|
21
|
+
function load() {
|
|
22
|
+
if (store) return store;
|
|
23
|
+
const raw = readJsonWithFallback(STATE_FILE, null) || {};
|
|
24
|
+
const rawConn = raw.connection && typeof raw.connection === "object" ? raw.connection : {};
|
|
25
|
+
const rawCtrl = raw.control && typeof raw.control === "object" ? raw.control : {};
|
|
26
|
+
store = {
|
|
27
|
+
processed: Array.isArray(raw.processed) ? raw.processed.slice(-PROCESSED_CAP) : [],
|
|
28
|
+
tasks: raw.tasks && typeof raw.tasks === "object" ? raw.tasks : {},
|
|
29
|
+
// Connection signal (hybrid): central authoritatively lists Spaces among the
|
|
30
|
+
// bot's connected apps, OR the socket has actually received a Spaces
|
|
31
|
+
// notification (which only happens for a task the bot is a member of). Either
|
|
32
|
+
// one means "connected" — no env flag required.
|
|
33
|
+
connection: {
|
|
34
|
+
central: !!rawConn.central,
|
|
35
|
+
centralAt: rawConn.centralAt || 0,
|
|
36
|
+
socketFirstAt: rawConn.socketFirstAt || 0,
|
|
37
|
+
socketLastAt: rawConn.socketLastAt || 0,
|
|
38
|
+
},
|
|
39
|
+
// Owner control, set from chat (/spaces …) — never from env/filesystem, which
|
|
40
|
+
// the owner can't reach on a pod. `muted` is the off-switch for auto-replies;
|
|
41
|
+
// `taskLoopMode` overrides the env default posture.
|
|
42
|
+
control: {
|
|
43
|
+
muted: !!rawCtrl.muted,
|
|
44
|
+
taskLoopMode: rawCtrl.taskLoopMode || null,
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
return store;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function save() {
|
|
51
|
+
if (!store) return;
|
|
52
|
+
try { atomicWriteFileSync(STATE_FILE, JSON.stringify(store, null, 2), { backup: true }); }
|
|
53
|
+
catch (e) { console.error("spaces-state: write failed:", e.message); }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Record a notification id as handled. Returns true if it was NEW (proceed),
|
|
57
|
+
// false if it had already been processed in this or a previous life (skip).
|
|
58
|
+
function markProcessed(notificationId) {
|
|
59
|
+
const id = String(notificationId || "");
|
|
60
|
+
if (!id) return false;
|
|
61
|
+
const s = load();
|
|
62
|
+
if (s.processed.includes(id)) return false;
|
|
63
|
+
s.processed.push(id);
|
|
64
|
+
if (s.processed.length > PROCESSED_CAP) s.processed = s.processed.slice(-PROCESSED_CAP);
|
|
65
|
+
save();
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Advance a task's cursor. Called for every task notification (awareness too)
|
|
70
|
+
// so the cursor always reflects the last activity we observed on the task.
|
|
71
|
+
function recordCursor(taskId, { notificationId = "", type = "" } = {}) {
|
|
72
|
+
const id = String(taskId || "");
|
|
73
|
+
if (!id) return;
|
|
74
|
+
const s = load();
|
|
75
|
+
const cur = s.tasks[id] || { count: 0 };
|
|
76
|
+
s.tasks[id] = {
|
|
77
|
+
lastNotificationId: String(notificationId || cur.lastNotificationId || ""),
|
|
78
|
+
lastType: String(type || cur.lastType || ""),
|
|
79
|
+
lastAt: new Date().toISOString(),
|
|
80
|
+
count: (cur.count || 0) + 1,
|
|
81
|
+
};
|
|
82
|
+
save();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function getCursor(taskId) {
|
|
86
|
+
const s = load();
|
|
87
|
+
return s.tasks[String(taskId || "")] || null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function snapshot() {
|
|
91
|
+
const s = load();
|
|
92
|
+
return { tasks: s.tasks, processedCount: s.processed.length };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// --- Connection signal -----------------------------------------------------
|
|
96
|
+
|
|
97
|
+
// Called on every inbound Spaces notification. Receiving one is proof the bot is
|
|
98
|
+
// a member of that task's space, so it flips the socket-connected signal on.
|
|
99
|
+
function markSocketConnected() {
|
|
100
|
+
const s = load();
|
|
101
|
+
const now = Date.now();
|
|
102
|
+
if (!s.connection.socketFirstAt) s.connection.socketFirstAt = now;
|
|
103
|
+
s.connection.socketLastAt = now;
|
|
104
|
+
save();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Called by the connected-apps poller with central's authoritative answer to
|
|
108
|
+
// "is Spaces among my connected apps?". Persisted so a restart keeps the signal
|
|
109
|
+
// until the next poll refreshes it.
|
|
110
|
+
function setCentralConnected(connected) {
|
|
111
|
+
const s = load();
|
|
112
|
+
const val = !!connected;
|
|
113
|
+
if (s.connection.central === val && s.connection.centralAt) return;
|
|
114
|
+
s.connection.central = val;
|
|
115
|
+
s.connection.centralAt = Date.now();
|
|
116
|
+
save();
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Hybrid: connected if central says so, or the socket has ever delivered a
|
|
120
|
+
// Spaces notification. Membership is the real-world gate — you can't receive a
|
|
121
|
+
// notification for a space you're not in — so socket-ever-connected is a safe,
|
|
122
|
+
// sticky signal.
|
|
123
|
+
function isConnected() {
|
|
124
|
+
const s = load();
|
|
125
|
+
return !!(s.connection.central || s.connection.socketFirstAt);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// --- Owner control (from chat) ---------------------------------------------
|
|
129
|
+
|
|
130
|
+
function setMuted(muted) {
|
|
131
|
+
const s = load();
|
|
132
|
+
s.control.muted = !!muted;
|
|
133
|
+
save();
|
|
134
|
+
return s.control.muted;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function setTaskLoopMode(mode) {
|
|
138
|
+
const s = load();
|
|
139
|
+
const m = String(mode || "").toLowerCase();
|
|
140
|
+
s.control.taskLoopMode = ["advisory", "autonomous", "off"].includes(m) ? m : null;
|
|
141
|
+
save();
|
|
142
|
+
return s.control.taskLoopMode;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// The task-working posture for engaged Spaces turns: chat override wins, else the
|
|
146
|
+
// env default the caller passes ("advisory" unless overridden).
|
|
147
|
+
function taskLoopMode(envDefault = "advisory") {
|
|
148
|
+
const s = load();
|
|
149
|
+
return s.control.taskLoopMode || String(envDefault || "advisory").toLowerCase();
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// The single gate the adapter/loopback/access all read: auto-replies are live
|
|
153
|
+
// when the bot is connected to Spaces AND the owner hasn't paused them.
|
|
154
|
+
function conversationalActive() {
|
|
155
|
+
const s = load();
|
|
156
|
+
return isConnected() && !s.control.muted;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// For /spaces status.
|
|
160
|
+
function controlSnapshot() {
|
|
161
|
+
const s = load();
|
|
162
|
+
return {
|
|
163
|
+
connected: isConnected(),
|
|
164
|
+
central: s.connection.central,
|
|
165
|
+
socketFirstAt: s.connection.socketFirstAt,
|
|
166
|
+
socketLastAt: s.connection.socketLastAt,
|
|
167
|
+
muted: s.control.muted,
|
|
168
|
+
conversationalActive: conversationalActive(),
|
|
169
|
+
taskLoopMode: taskLoopMode(),
|
|
170
|
+
taskLoopOverride: s.control.taskLoopMode,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
module.exports = {
|
|
175
|
+
markProcessed, recordCursor, getCursor, snapshot, STATE_FILE,
|
|
176
|
+
markSocketConnected, setCentralConnected, isConnected,
|
|
177
|
+
setMuted, setTaskLoopMode, taskLoopMode, conversationalActive, controlSnapshot,
|
|
178
|
+
};
|
package/core/access.js
CHANGED
|
@@ -52,6 +52,16 @@ function isChatAuthorized(chatId) {
|
|
|
52
52
|
if (matchesTransportOwner()) return true;
|
|
53
53
|
const id = String(chatId || "");
|
|
54
54
|
if (!id) return false;
|
|
55
|
+
// Spaces task threads authorize by participation: the bot only receives a
|
|
56
|
+
// Spaces notification for a task it follows/was mentioned on, so the inbound
|
|
57
|
+
// itself is the authorization signal. Effective only while the conversational
|
|
58
|
+
// plane is active (connected to Spaces AND the owner hasn't paused replies).
|
|
59
|
+
try {
|
|
60
|
+
if (currentTransport() === "spaces"
|
|
61
|
+
&& require("../channels/spaces/state").conversationalActive()) {
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
} catch (e) {}
|
|
55
65
|
if (CHAT_IDS.includes(id)) return true;
|
|
56
66
|
try {
|
|
57
67
|
const auth = JSON.parse(fs.readFileSync(AUTH_FILE, "utf-8"));
|
package/core/actions.js
CHANGED
|
@@ -443,6 +443,14 @@ async function handleAction(envelope) {
|
|
|
443
443
|
: `Could not deliver the reply.`);
|
|
444
444
|
}
|
|
445
445
|
|
|
446
|
+
// Mirror the owner's decision back into the originating Spaces task thread
|
|
447
|
+
// (non-binding status; the binding decision happened here). Scoped to plain
|
|
448
|
+
// tool runs — external reply/action escalations resume their own turn and
|
|
449
|
+
// report there, so mirroring here would double-post.
|
|
450
|
+
if (rec.kind === "tool" && rec.adapter === "spaces") {
|
|
451
|
+
try { require("../channels/spaces/approval-mirror").mirrorOutcome(rec).catch(() => {}); } catch (e) {}
|
|
452
|
+
}
|
|
453
|
+
|
|
446
454
|
// If a second press disagreed, rec.status still holds the FIRST decision.
|
|
447
455
|
const label = `${rec.tool}${rec.verb ? " " + rec.verb : ""}`;
|
|
448
456
|
// Always-allow on an external ACTION escalation folds the command into the
|
package/core/adapter-registry.js
CHANGED
|
@@ -12,6 +12,7 @@ const { setAdapters } = require("./scheduler");
|
|
|
12
12
|
|
|
13
13
|
const { TelegramAdapter } = require("../channels/telegram/adapter");
|
|
14
14
|
const { KazeeAdapter } = require("../channels/kazee/adapter");
|
|
15
|
+
const { SpacesAdapter } = require("../channels/spaces/adapter");
|
|
15
16
|
const { VoiceAdapter } = require("../channels/voice/adapter");
|
|
16
17
|
const { WebAdapter } = require("../channels/web/adapter");
|
|
17
18
|
|
|
@@ -22,6 +23,7 @@ let actionHandler = null;
|
|
|
22
23
|
function createAdapter(spec) {
|
|
23
24
|
if (spec.type === "telegram") return new TelegramAdapter({ id: spec.id, ...spec.opts });
|
|
24
25
|
if (spec.type === "kazee") return new KazeeAdapter({ id: spec.id, ...spec.opts });
|
|
26
|
+
if (spec.type === "spaces") return new SpacesAdapter({ id: spec.id, ...spec.opts });
|
|
25
27
|
if (spec.type === "voice") return new VoiceAdapter({ id: spec.id, ...spec.opts });
|
|
26
28
|
if (spec.type === "web") return new WebAdapter({ id: spec.id, ...spec.opts });
|
|
27
29
|
console.error(`Unknown adapter type: ${spec.type}`);
|
package/core/config.js
CHANGED
|
@@ -193,6 +193,10 @@ const FULL_PATH = [
|
|
|
193
193
|
// for the bot token (the actual secret). Override with KAZEE_URL if ever needed.
|
|
194
194
|
const KAZEE_DEFAULT_URL = "https://chat.inet.africa";
|
|
195
195
|
|
|
196
|
+
// Kazee Spaces is fixed infrastructure too. The bot authenticates with its own
|
|
197
|
+
// kzb_ token (KAZEE_BOT_TOKEN) — Spaces accepts it directly, so no new secret.
|
|
198
|
+
const SPACES_DEFAULT_URL = "https://api.spaces.kazee.africa";
|
|
199
|
+
|
|
196
200
|
// Channels: declarative list of {id, type, opts}. Defaults to telegram for
|
|
197
201
|
// backwards compat with installs predating CHANNELS.
|
|
198
202
|
function loadChannels() {
|
|
@@ -231,6 +235,32 @@ function loadChannels() {
|
|
|
231
235
|
type: "kazee",
|
|
232
236
|
opts: { url, token, ownerUserId, botUserId },
|
|
233
237
|
});
|
|
238
|
+
} else if (type === "spaces") {
|
|
239
|
+
// Conversational plane for Kazee Spaces. Bot authenticates with its own
|
|
240
|
+
// kzb_ token (the same KAZEE_BOT_TOKEN the connected-apps poller uses).
|
|
241
|
+
const url = config.SPACES_URL || SPACES_DEFAULT_URL;
|
|
242
|
+
const token = config.KAZEE_BOT_TOKEN;
|
|
243
|
+
if (!token) {
|
|
244
|
+
console.error(`CHANNELS includes ${entry} but KAZEE_BOT_TOKEN is unset — skipping.`);
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
channels.push({
|
|
248
|
+
id,
|
|
249
|
+
type: "spaces",
|
|
250
|
+
opts: {
|
|
251
|
+
url,
|
|
252
|
+
token,
|
|
253
|
+
ownerUserId: config.SPACES_OWNER_USER_ID || "",
|
|
254
|
+
// Conversational auto-replies are no longer env-gated: the adapter
|
|
255
|
+
// enables them itself once Spaces is CONNECTED (central lists it, or
|
|
256
|
+
// the socket receives a notification) and the owner hasn't paused via
|
|
257
|
+
// /spaces. The socket always starts here for awareness.
|
|
258
|
+
// taskLoopMode is only the DEFAULT posture; /spaces mode overrides it
|
|
259
|
+
// live. "advisory" = read/reason/recommend; "autonomous" = work to
|
|
260
|
+
// completion (writes still hit the owner-approval gate); "off".
|
|
261
|
+
taskLoopMode: String(config.OC_SPACES_TASK_LOOP || process.env.OC_SPACES_TASK_LOOP || "advisory").toLowerCase(),
|
|
262
|
+
},
|
|
263
|
+
});
|
|
234
264
|
} else if (type === "voice") {
|
|
235
265
|
const token = config.VOICE_BRIDGE_TOKEN;
|
|
236
266
|
if (!token) {
|
|
@@ -251,6 +281,23 @@ function loadChannels() {
|
|
|
251
281
|
console.error(`Unknown channel type: ${type} — skipping.`);
|
|
252
282
|
}
|
|
253
283
|
}
|
|
284
|
+
// Auto-include the Spaces channel whenever the bot has its own kzb_ token, even
|
|
285
|
+
// if CHANNELS didn't list it. Spaces is fixed infra on the same token as Kazee
|
|
286
|
+
// chat, and the owner can't edit CHANNELS on a pod — so "have token ⇒ Spaces
|
|
287
|
+
// available" is what makes auto-enable reachable without touching env. It's
|
|
288
|
+
// self-gating: no Spaces membership ⇒ no notifications ⇒ no replies.
|
|
289
|
+
if ((config.KAZEE_BOT_TOKEN) && !channels.some((c) => c.type === "spaces")) {
|
|
290
|
+
channels.push({
|
|
291
|
+
id: "spaces",
|
|
292
|
+
type: "spaces",
|
|
293
|
+
opts: {
|
|
294
|
+
url: config.SPACES_URL || SPACES_DEFAULT_URL,
|
|
295
|
+
token: config.KAZEE_BOT_TOKEN,
|
|
296
|
+
ownerUserId: config.SPACES_OWNER_USER_ID || "",
|
|
297
|
+
taskLoopMode: String(config.OC_SPACES_TASK_LOOP || process.env.OC_SPACES_TASK_LOOP || "advisory").toLowerCase(),
|
|
298
|
+
},
|
|
299
|
+
});
|
|
300
|
+
}
|
|
254
301
|
return channels;
|
|
255
302
|
}
|
|
256
303
|
|
|
@@ -305,6 +352,7 @@ module.exports = {
|
|
|
305
352
|
UTILITY_PURPOSE_PROVIDERS,
|
|
306
353
|
ensureRuntimeDirectories,
|
|
307
354
|
KAZEE_DEFAULT_URL,
|
|
355
|
+
SPACES_DEFAULT_URL,
|
|
308
356
|
BOT_DIR,
|
|
309
357
|
CONFIG_DIR,
|
|
310
358
|
TOKEN, CHAT_IDS, CHAT_ID,
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
// Connected-apps subsystem — the data/awareness plane.
|
|
2
|
+
//
|
|
3
|
+
// The bot uses its OWN control-plane token (KAZEE_BOT_TOKEN, an opaque kzb_…)
|
|
4
|
+
// to ask inet-central "which apps am I connected to?" (GET
|
|
5
|
+
// /api/principal/applications — the self-service endpoint added alongside this),
|
|
6
|
+
// then, for each connected app that has a registered connector, polls it for
|
|
7
|
+
// awareness (Spaces first: unread notifications + tasks). This runs ONLY in the
|
|
8
|
+
// parent bot process — the token is a CONTROL_PLANE_KEY stripped from every
|
|
9
|
+
// provider/tool subprocess, so a connector can never leak into a coding-agent
|
|
10
|
+
// child.
|
|
11
|
+
//
|
|
12
|
+
// Scalable by construction: adding an app = drop in a connector + list the app
|
|
13
|
+
// in central. No core changes. This module knows nothing app-specific beyond
|
|
14
|
+
// the connectors that register themselves.
|
|
15
|
+
//
|
|
16
|
+
// SAFETY: everything here is READ-ONLY. It discovers and observes; it never
|
|
17
|
+
// writes to central or to any app. New items are recorded in connected-apps.json
|
|
18
|
+
// for on-demand querying — no agent turn is auto-triggered from this plane. The
|
|
19
|
+
// conversational plane (auto-replies) is a separate, opt-in channel adapter.
|
|
20
|
+
|
|
21
|
+
const https = require("https");
|
|
22
|
+
const http = require("http");
|
|
23
|
+
const path = require("path");
|
|
24
|
+
const { URL } = require("url");
|
|
25
|
+
const { config, CONFIG_DIR } = require("./config");
|
|
26
|
+
const { atomicWriteFileSync, readJsonWithFallback } = require("./fsutil");
|
|
27
|
+
const { truthy } = require("../project-transcripts");
|
|
28
|
+
|
|
29
|
+
const STORE_FILE = path.join(CONFIG_DIR, "connected-apps.json");
|
|
30
|
+
const CENTRAL_URL = (config.CENTRAL_URL || "https://central.inet.africa").replace(/\/$/, "");
|
|
31
|
+
|
|
32
|
+
const APPS_REFRESH_MS = 15 * 60 * 1000; // re-ask central which apps we're on
|
|
33
|
+
const POLL_INTERVAL_MS = 90 * 1000; // per-app awareness poll cadence
|
|
34
|
+
const BACKOFF_BASE_MS = 60 * 1000;
|
|
35
|
+
const BACKOFF_MAX_MS = 30 * 60 * 1000;
|
|
36
|
+
const INBOX_CAP = 100;
|
|
37
|
+
|
|
38
|
+
let timer = null;
|
|
39
|
+
let store = null;
|
|
40
|
+
const connectors = new Map(); // appKey -> connector
|
|
41
|
+
|
|
42
|
+
function botToken() {
|
|
43
|
+
return config.KAZEE_BOT_TOKEN || process.env.KAZEE_BOT_TOKEN || "";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function loadStore() {
|
|
47
|
+
const raw = readJsonWithFallback(STORE_FILE, null) || {};
|
|
48
|
+
return {
|
|
49
|
+
appsRefreshedAt: raw.appsRefreshedAt || 0,
|
|
50
|
+
apps: Array.isArray(raw.apps) ? raw.apps : [],
|
|
51
|
+
perApp: raw.perApp && typeof raw.perApp === "object" ? raw.perApp : {},
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function saveStore() {
|
|
56
|
+
try { atomicWriteFileSync(STORE_FILE, JSON.stringify(store, null, 2), { backup: true }); }
|
|
57
|
+
catch (e) { console.error("connected-apps: store write failed:", e.message); }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function appState(key) {
|
|
61
|
+
if (!store.perApp[key]) {
|
|
62
|
+
store.perApp[key] = { lastPollAt: 0, status: "idle", backoffUntil: 0, backoffMs: 0, seen: [], inbox: [] };
|
|
63
|
+
}
|
|
64
|
+
return store.perApp[key];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function getJson(baseUrl, urlPath, token) {
|
|
68
|
+
return new Promise((resolve, reject) => {
|
|
69
|
+
const u = new URL(baseUrl.replace(/\/$/, "") + urlPath);
|
|
70
|
+
const lib = u.protocol === "https:" ? https : http;
|
|
71
|
+
const req = lib.request(
|
|
72
|
+
{
|
|
73
|
+
method: "GET",
|
|
74
|
+
hostname: u.hostname,
|
|
75
|
+
port: u.port || (u.protocol === "https:" ? 443 : 80),
|
|
76
|
+
path: u.pathname + (u.search || ""),
|
|
77
|
+
headers: { Authorization: `Bearer ${token}`, Accept: "application/json" },
|
|
78
|
+
},
|
|
79
|
+
(res) => {
|
|
80
|
+
let chunks = "";
|
|
81
|
+
res.on("data", (c) => { chunks += c; });
|
|
82
|
+
res.on("end", () => {
|
|
83
|
+
if (res.statusCode >= 200 && res.statusCode < 300) {
|
|
84
|
+
try { resolve(chunks ? JSON.parse(chunks) : {}); }
|
|
85
|
+
catch (e) { resolve({}); }
|
|
86
|
+
} else {
|
|
87
|
+
reject(new Error(`GET ${urlPath} → ${res.statusCode}: ${chunks.slice(0, 200)}`));
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
},
|
|
91
|
+
);
|
|
92
|
+
req.on("error", reject);
|
|
93
|
+
req.setTimeout(30000, () => req.destroy(new Error(`GET ${urlPath} timeout`)));
|
|
94
|
+
req.end();
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
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() {
|
|
101
|
+
const token = botToken();
|
|
102
|
+
if (!token) return [];
|
|
103
|
+
const res = await getJson(CENTRAL_URL, "/api/principal/applications", token);
|
|
104
|
+
return Array.isArray(res?.applications) ? res.applications : [];
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function registerConnector(connector) {
|
|
108
|
+
if (!connector || !connector.appKey) throw new Error("connector needs an appKey");
|
|
109
|
+
connectors.set(connector.appKey, connector);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function connectorFor(app) {
|
|
113
|
+
for (const c of connectors.values()) {
|
|
114
|
+
try { if (c.match(app)) return c; } catch (e) {}
|
|
115
|
+
}
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Record awareness items into the app's capped inbox, deduped by id. Returns
|
|
120
|
+
// the count of genuinely new items.
|
|
121
|
+
function recordItems(state, items) {
|
|
122
|
+
const seen = new Set(state.seen);
|
|
123
|
+
let added = 0;
|
|
124
|
+
for (const it of items) {
|
|
125
|
+
const id = String(it.id || "");
|
|
126
|
+
if (!id || seen.has(id)) continue;
|
|
127
|
+
seen.add(id);
|
|
128
|
+
state.inbox.unshift(it);
|
|
129
|
+
added += 1;
|
|
130
|
+
}
|
|
131
|
+
state.seen = [...seen].slice(-INBOX_CAP * 2);
|
|
132
|
+
state.inbox = state.inbox.slice(0, INBOX_CAP);
|
|
133
|
+
return added;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function pollApp(app) {
|
|
137
|
+
const key = String(app.id || app.name);
|
|
138
|
+
const state = appState(key);
|
|
139
|
+
const now = Date.now();
|
|
140
|
+
if (state.backoffUntil && now < state.backoffUntil) return;
|
|
141
|
+
const connector = connectorFor(app);
|
|
142
|
+
if (!connector) { state.status = "no-connector"; return; }
|
|
143
|
+
try {
|
|
144
|
+
const items = await connector.poll(app, botToken());
|
|
145
|
+
const added = recordItems(state, items || []);
|
|
146
|
+
state.lastPollAt = now;
|
|
147
|
+
state.status = "ok";
|
|
148
|
+
state.backoffMs = 0;
|
|
149
|
+
state.backoffUntil = 0;
|
|
150
|
+
if (added) console.log(`connected-apps: ${connector.appKey} — ${added} new item(s)`);
|
|
151
|
+
} catch (e) {
|
|
152
|
+
state.status = `error: ${e.message}`;
|
|
153
|
+
state.backoffMs = Math.min(state.backoffMs ? state.backoffMs * 2 : BACKOFF_BASE_MS, BACKOFF_MAX_MS);
|
|
154
|
+
state.backoffUntil = now + state.backoffMs;
|
|
155
|
+
console.error(`connected-apps: ${key} poll failed, backing off ${Math.round(state.backoffMs / 1000)}s:`, e.message);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async function tick() {
|
|
160
|
+
const now = Date.now();
|
|
161
|
+
if (now - store.appsRefreshedAt > APPS_REFRESH_MS) {
|
|
162
|
+
try {
|
|
163
|
+
const apps = await fetchConnectedApps();
|
|
164
|
+
if (apps.length || store.apps.length === 0) {
|
|
165
|
+
store.apps = apps;
|
|
166
|
+
store.appsRefreshedAt = now;
|
|
167
|
+
}
|
|
168
|
+
} catch (e) {
|
|
169
|
+
console.error("connected-apps: apps refresh failed:", e.message);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
// Central's authoritative half of the "connected ⇒ conversational" signal:
|
|
173
|
+
// tell the Spaces conversational plane whether central lists Spaces among this
|
|
174
|
+
// bot's apps. The socket half is set independently when a notification lands,
|
|
175
|
+
// so auto-replies work even before this endpoint is deployed.
|
|
176
|
+
try {
|
|
177
|
+
const spacesConnected = store.apps.some((a) => {
|
|
178
|
+
const c = connectorFor(a);
|
|
179
|
+
return c && c.appKey === "spaces";
|
|
180
|
+
});
|
|
181
|
+
require("../channels/spaces/state").setCentralConnected(spacesConnected);
|
|
182
|
+
} catch (e) {}
|
|
183
|
+
|
|
184
|
+
for (const app of store.apps) {
|
|
185
|
+
if (!connectorFor(app)) continue;
|
|
186
|
+
await pollApp(app);
|
|
187
|
+
}
|
|
188
|
+
saveStore();
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Public: current awareness snapshot (for an on-demand "what's in my inbox").
|
|
192
|
+
function snapshot() {
|
|
193
|
+
if (!store) store = loadStore();
|
|
194
|
+
return {
|
|
195
|
+
apps: store.apps.map((a) => ({ id: a.id, name: a.name })),
|
|
196
|
+
perApp: Object.fromEntries(
|
|
197
|
+
Object.entries(store.perApp).map(([k, v]) => [k, {
|
|
198
|
+
status: v.status,
|
|
199
|
+
lastPollAt: v.lastPollAt,
|
|
200
|
+
unread: v.inbox.length,
|
|
201
|
+
inbox: v.inbox.slice(0, 20),
|
|
202
|
+
}]),
|
|
203
|
+
),
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function initConnectedApps() {
|
|
208
|
+
if (!truthy(config.OC_CONNECTED_APPS || process.env.OC_CONNECTED_APPS, false)) {
|
|
209
|
+
return { started: false, reason: "OC_CONNECTED_APPS disabled" };
|
|
210
|
+
}
|
|
211
|
+
if (!botToken()) {
|
|
212
|
+
return { started: false, reason: "KAZEE_BOT_TOKEN unset" };
|
|
213
|
+
}
|
|
214
|
+
store = loadStore();
|
|
215
|
+
// Register built-in connectors.
|
|
216
|
+
try { registerConnector(require("./connectors/spaces")); }
|
|
217
|
+
catch (e) { console.error("connected-apps: spaces connector load failed:", e.message); }
|
|
218
|
+
// First tick shortly after boot, then on the poll cadence.
|
|
219
|
+
setTimeout(() => { tick().catch((e) => console.error("connected-apps tick:", e.message)); }, 20000);
|
|
220
|
+
timer = setInterval(() => { tick().catch((e) => console.error("connected-apps tick:", e.message)); }, POLL_INTERVAL_MS);
|
|
221
|
+
return { started: true, connectors: [...connectors.keys()] };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function stopConnectedApps() {
|
|
225
|
+
if (timer) { clearInterval(timer); timer = null; }
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
module.exports = {
|
|
229
|
+
initConnectedApps,
|
|
230
|
+
stopConnectedApps,
|
|
231
|
+
registerConnector,
|
|
232
|
+
fetchConnectedApps,
|
|
233
|
+
snapshot,
|
|
234
|
+
STORE_FILE,
|
|
235
|
+
};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// Spaces awareness connector. Registered with the connected-apps poller; polls
|
|
2
|
+
// the bot's own unread Spaces notifications (read-only) and returns them as
|
|
3
|
+
// normalised awareness items. This is the data plane only — it observes and
|
|
4
|
+
// records; it never replies. Replies are the separate conversational channel
|
|
5
|
+
// adapter (channels/spaces/adapter.js).
|
|
6
|
+
|
|
7
|
+
const { SpacesClient, contentText } = require("../../channels/spaces/client");
|
|
8
|
+
const { config } = require("../config");
|
|
9
|
+
|
|
10
|
+
const SPACES_URL = (config.SPACES_URL || "https://api.spaces.kazee.africa").replace(/\/$/, "");
|
|
11
|
+
|
|
12
|
+
module.exports = {
|
|
13
|
+
appKey: "spaces",
|
|
14
|
+
|
|
15
|
+
// Central lists the app by name/domain. Match either.
|
|
16
|
+
match(app) {
|
|
17
|
+
const hay = `${app?.name || ""} ${app?.domainName || ""}`.toLowerCase();
|
|
18
|
+
return hay.includes("spaces");
|
|
19
|
+
},
|
|
20
|
+
|
|
21
|
+
async poll(_app, token) {
|
|
22
|
+
const client = new SpacesClient({ baseUrl: SPACES_URL, token });
|
|
23
|
+
const notifications = await client.listNotifications({ limit: 40, unreadOnly: true });
|
|
24
|
+
return notifications.map((n) => {
|
|
25
|
+
const inner = (n.data && n.data.payload) || {};
|
|
26
|
+
const actor = n.actorId || {};
|
|
27
|
+
const actorName = typeof actor === "object"
|
|
28
|
+
? (`${actor.firstName || ""} ${actor.lastName || ""}`.trim() || actor.email || "")
|
|
29
|
+
: String(actor);
|
|
30
|
+
return {
|
|
31
|
+
id: String(n.id || n._id || ""),
|
|
32
|
+
type: String(n.category || n.type || "").toLowerCase(),
|
|
33
|
+
actorName,
|
|
34
|
+
taskId: String(n.sourceEntityId || inner.taskId || inner.targetId || ""),
|
|
35
|
+
taskTitle: inner.taskTitle || inner.targetTitle || n.title || "",
|
|
36
|
+
text: contentText(n.content || n.title || "").slice(0, 300),
|
|
37
|
+
createdAt: n.createdAt || null,
|
|
38
|
+
};
|
|
39
|
+
});
|
|
40
|
+
},
|
|
41
|
+
};
|
package/core/handlers.js
CHANGED
|
@@ -793,6 +793,55 @@ register({
|
|
|
793
793
|
},
|
|
794
794
|
});
|
|
795
795
|
|
|
796
|
+
register({
|
|
797
|
+
name: "spaces", description: "Control Kazee Spaces auto-replies (status/pause/resume/mode)",
|
|
798
|
+
args: "<status | pause | resume | mode advisory|autonomous|off>", ownerOnly: true,
|
|
799
|
+
handler: async (env, { tail }) => {
|
|
800
|
+
if (!ownerEnv(env)) return;
|
|
801
|
+
let spacesState;
|
|
802
|
+
try { spacesState = require("../channels/spaces/state"); }
|
|
803
|
+
catch (e) { return send("Spaces state unavailable — the Spaces channel isn't loaded."); }
|
|
804
|
+
const parts = (tail || "").trim().split(/\s+/).filter(Boolean);
|
|
805
|
+
const op = (parts[0] || "status").toLowerCase();
|
|
806
|
+
|
|
807
|
+
const fmtTime = (ms) => (ms ? new Date(ms).toISOString().replace("T", " ").slice(0, 16) + "Z" : "never");
|
|
808
|
+
const statusText = () => {
|
|
809
|
+
const s = spacesState.controlSnapshot();
|
|
810
|
+
const src = s.central ? "central" : (s.socketFirstAt ? "socket" : "—");
|
|
811
|
+
const replies = s.conversationalActive ? "ON" : (s.connected ? "paused" : "off (not connected)");
|
|
812
|
+
const modeLine = s.taskLoopOverride
|
|
813
|
+
? `${s.taskLoopMode} (override)`
|
|
814
|
+
: `${s.taskLoopMode} (default)`;
|
|
815
|
+
return [
|
|
816
|
+
"<b>Kazee Spaces</b>",
|
|
817
|
+
`Connected: ${s.connected ? "yes" : "no"}${s.connected ? ` (via ${src})` : ""}`,
|
|
818
|
+
`Auto-replies: ${replies}`,
|
|
819
|
+
`Task mode: ${modeLine}`,
|
|
820
|
+
`Last notification: ${fmtTime(s.socketLastAt)}`,
|
|
821
|
+
].join("\n");
|
|
822
|
+
};
|
|
823
|
+
|
|
824
|
+
if (op === "status") return send(statusText());
|
|
825
|
+
if (op === "pause") {
|
|
826
|
+
spacesState.setMuted(true);
|
|
827
|
+
return send("Spaces auto-replies <b>paused</b>. Mentions/replies still hit the radar; I just won't answer until you /spaces resume.\n\n" + statusText());
|
|
828
|
+
}
|
|
829
|
+
if (op === "resume") {
|
|
830
|
+
spacesState.setMuted(false);
|
|
831
|
+
return send("Spaces auto-replies <b>resumed</b>.\n\n" + statusText());
|
|
832
|
+
}
|
|
833
|
+
if (op === "mode") {
|
|
834
|
+
const m = (parts[1] || "").toLowerCase();
|
|
835
|
+
if (!["advisory", "autonomous", "off"].includes(m)) {
|
|
836
|
+
return send("Usage: /spaces mode <advisory|autonomous|off>\n\n• advisory — read, reason, recommend (no autonomous write chains)\n• autonomous — work to completion (writes still need your approval)\n• off — no task-mode framing");
|
|
837
|
+
}
|
|
838
|
+
spacesState.setTaskLoopMode(m);
|
|
839
|
+
return send(`Spaces task mode set to <b>${m}</b>.\n\n` + statusText());
|
|
840
|
+
}
|
|
841
|
+
return send("Usage: /spaces <status | pause | resume | mode advisory|autonomous|off>");
|
|
842
|
+
},
|
|
843
|
+
});
|
|
844
|
+
|
|
796
845
|
register({
|
|
797
846
|
name: "cluster", description: "Control this bot's own deployment (status/logs/restart/start/stop/scale/sync)",
|
|
798
847
|
args: "<status|logs|restart|start|stop|scale 0|1|sync>", ownerOnly: true,
|