@inetafrica/open-claudia 3.0.10 → 3.0.12
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/bot.js +17 -5
- package/channels/kazee/adapter.js +24 -0
- package/core/boot-onboarding.js +212 -0
- package/core/router.js +12 -0
- package/package.json +1 -1
- package/test-provider-language.js +6 -5
- package/web.js +99 -40
package/bot.js
CHANGED
|
@@ -74,8 +74,14 @@ registry.setHandlers({ onMessage, onAction });
|
|
|
74
74
|
const adapters = registry.bootstrap();
|
|
75
75
|
|
|
76
76
|
if (adapters.length === 0) {
|
|
77
|
-
|
|
78
|
-
|
|
77
|
+
if (process.env.WEB_UI === "true") {
|
|
78
|
+
// Channel-less pods are valid: the dashboard (and AgentSpace) can attach
|
|
79
|
+
// telegram/kazee later, and addAdapter() boots them without a restart.
|
|
80
|
+
console.log("No channels configured yet — waiting for one to be attached via the dashboard.");
|
|
81
|
+
} else {
|
|
82
|
+
console.error("No channels configured. Set CHANNELS=telegram and TELEGRAM_BOT_TOKEN/TELEGRAM_CHAT_ID, or CHANNELS=kazee with KAZEE_URL/KAZEE_BOT_TOKEN.");
|
|
83
|
+
process.exit(1);
|
|
84
|
+
}
|
|
79
85
|
}
|
|
80
86
|
|
|
81
87
|
// ── Graceful shutdown & error handling ─────────────────────────────
|
|
@@ -290,16 +296,22 @@ setInterval(checkForUpdates, 5 * 60 * 1000);
|
|
|
290
296
|
|
|
291
297
|
// Notify owner that bot is back online via every adapter that knows
|
|
292
298
|
// an owner channel. Kazee skipped: ownerUserId is a user id, not a
|
|
293
|
-
// chat_id
|
|
294
|
-
//
|
|
299
|
+
// chat_id — boot onboarding below opens the DM when outreach is needed.
|
|
300
|
+
// Skipped entirely pre-onboarding: a fresh pod's first words should be
|
|
301
|
+
// the onboarding greeting, not "Back online".
|
|
295
302
|
for (const a of adapters) {
|
|
296
303
|
try {
|
|
297
|
-
if (a.type === "telegram" && CHAT_ID) {
|
|
304
|
+
if (a.type === "telegram" && CHAT_ID && isOnboarded()) {
|
|
298
305
|
await a.send(CHAT_ID, `Back online and ready! Running v${CURRENT_VERSION}.`);
|
|
299
306
|
}
|
|
300
307
|
} catch (e) {}
|
|
301
308
|
}
|
|
302
309
|
|
|
310
|
+
// Proactive first-boot outreach: greet an env-provisioned owner, guide them
|
|
311
|
+
// through /login, then run the LLM onboarding interview once authed.
|
|
312
|
+
try { await require("./core/boot-onboarding").initBootOnboarding(adapters); }
|
|
313
|
+
catch (e) { console.error("boot onboarding failed:", e.message); }
|
|
314
|
+
|
|
303
315
|
// Surface jobs the provider migration disabled. Announce once per job:
|
|
304
316
|
// marked only after a successful send, so a down adapter retries next boot.
|
|
305
317
|
try {
|
|
@@ -264,6 +264,30 @@ class KazeeAdapter {
|
|
|
264
264
|
return null;
|
|
265
265
|
}
|
|
266
266
|
|
|
267
|
+
// Get-or-create the 1:1 chat between the bot and a user, returning its
|
|
268
|
+
// chatId. chat-central's createChat returns the existing DM when one
|
|
269
|
+
// already exists, so this is idempotent. Requires chats.create on the bot
|
|
270
|
+
// token — callers must treat null as "outreach unavailable", not fatal.
|
|
271
|
+
async openDirectChat(userId) {
|
|
272
|
+
if (!this.botUserId || !userId) return null;
|
|
273
|
+
try {
|
|
274
|
+
const res = await this._request("POST", "/chat", {
|
|
275
|
+
participants: { members: [String(this.botUserId), String(userId)] },
|
|
276
|
+
chatName: "Direct Message",
|
|
277
|
+
chatType: "chat",
|
|
278
|
+
isGroupChat: false,
|
|
279
|
+
});
|
|
280
|
+
if (res && res.success === false) {
|
|
281
|
+
console.error("Kazee openDirectChat rejected:", res.error || "unknown error");
|
|
282
|
+
return null;
|
|
283
|
+
}
|
|
284
|
+
return res && res.chatId ? String(res.chatId) : null;
|
|
285
|
+
} catch (e) {
|
|
286
|
+
console.error("Kazee openDirectChat error:", e.message);
|
|
287
|
+
return null;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
267
291
|
async edit(channelId, messageId, text, opts = {}) {
|
|
268
292
|
const buttons = this._normalizeKeyboard(opts.keyboard);
|
|
269
293
|
const body = { content: normalize(text) };
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
// Proactive first-boot outreach. A provisioned pod knows its owner from env
|
|
2
|
+
// (TELEGRAM_CHAT_ID / KAZEE_OWNER_USER_ID) but historically sat silent until
|
|
3
|
+
// the owner messaged first. Instead: greet the owner at boot, walk them
|
|
4
|
+
// through connecting a provider (/login), and once one is authenticated run
|
|
5
|
+
// an LLM-led interview that captures the bot's role and the owner's profile
|
|
6
|
+
// into the soul file. Flags persist in CONFIG_DIR so restarts and upgrades
|
|
7
|
+
// never re-ping; no env-provisioned owner means stay quiet and let /auth
|
|
8
|
+
// claim ownership exactly as before.
|
|
9
|
+
|
|
10
|
+
"use strict";
|
|
11
|
+
|
|
12
|
+
const fs = require("fs");
|
|
13
|
+
const path = require("path");
|
|
14
|
+
|
|
15
|
+
const { CONFIG_DIR, WORKSPACE, SOUL_FILE, config, saveEnvKey } = require("./config");
|
|
16
|
+
const { runInChat } = require("./context");
|
|
17
|
+
const { canonicalForChannel } = require("./identity");
|
|
18
|
+
|
|
19
|
+
const FLAGS_FILE = path.join(CONFIG_DIR, "boot-onboarding.json");
|
|
20
|
+
const AUTH_POLL_MS = 30 * 1000;
|
|
21
|
+
|
|
22
|
+
let authWatcher = null;
|
|
23
|
+
|
|
24
|
+
function readFlags() {
|
|
25
|
+
try { return JSON.parse(fs.readFileSync(FLAGS_FILE, "utf8")) || {}; }
|
|
26
|
+
catch (e) { return {}; }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function writeFlags(patch) {
|
|
30
|
+
const flags = { ...readFlags(), ...patch };
|
|
31
|
+
try { fs.writeFileSync(FLAGS_FILE, JSON.stringify(flags, null, 2)); }
|
|
32
|
+
catch (e) { console.error("boot-onboarding: could not persist flags:", e.message); }
|
|
33
|
+
return flags;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function isOnboarded() {
|
|
37
|
+
return config.ONBOARDED === "true";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Authoritative auth check via the provider registry (probes CLI auth state,
|
|
41
|
+
// so API-key-provisioned pods count as authenticated — token presence alone
|
|
42
|
+
// would miss them). Registry caches healthy probes; negatives always re-probe.
|
|
43
|
+
function authenticatedProviderId() {
|
|
44
|
+
try {
|
|
45
|
+
const providers = require("./providers");
|
|
46
|
+
for (const provider of providers.listProviders()) {
|
|
47
|
+
try {
|
|
48
|
+
const status = providers.providerStatus(provider.id);
|
|
49
|
+
if (status.availability?.state === "available"
|
|
50
|
+
&& status.compatibility?.state !== "incompatible"
|
|
51
|
+
&& status.auth?.state === "authenticated") return provider.id;
|
|
52
|
+
} catch (e) { /* try the next provider */ }
|
|
53
|
+
}
|
|
54
|
+
} catch (e) {
|
|
55
|
+
console.error("boot-onboarding: provider status check failed:", e.message);
|
|
56
|
+
}
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function resolveOwnerChat(adapters) {
|
|
61
|
+
for (const a of adapters) {
|
|
62
|
+
if (a.type === "telegram" && a.ownerChatId) {
|
|
63
|
+
return { adapter: a, channelId: String(a.ownerChatId), userId: String(a.ownerChatId) };
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
for (const a of adapters) {
|
|
67
|
+
if (a.type === "kazee" && a.ownerUserId && typeof a.openDirectChat === "function") {
|
|
68
|
+
const chatId = await a.openDirectChat(a.ownerUserId);
|
|
69
|
+
if (chatId) return { adapter: a, channelId: String(chatId), userId: String(a.ownerUserId) };
|
|
70
|
+
console.error("boot-onboarding: could not open a Kazee DM with the owner — outreach skipped (bot token may lack chats.create).");
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// The scope must land in the same state bucket as the owner's own replies:
|
|
77
|
+
// kazee envelopes key canonical identity on the sender's userId, telegram DMs
|
|
78
|
+
// on the chat id — mirror that here or the interview forks into a bucket the
|
|
79
|
+
// owner's replies never reach.
|
|
80
|
+
function chatScope(ownerChat) {
|
|
81
|
+
return {
|
|
82
|
+
adapter: ownerChat.adapter,
|
|
83
|
+
channelId: ownerChat.channelId,
|
|
84
|
+
canonicalUserId: canonicalForChannel(ownerChat.adapter.type, ownerChat.userId),
|
|
85
|
+
userId: ownerChat.userId,
|
|
86
|
+
transport: ownerChat.adapter.type,
|
|
87
|
+
raw: null,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function greetingMessage() {
|
|
92
|
+
return [
|
|
93
|
+
"👋 Hi! I'm your new agent — I just came online.",
|
|
94
|
+
"",
|
|
95
|
+
"First things first: I need a brain. Send /login to pick a model and connect its provider — I'll walk you through it right here in chat.",
|
|
96
|
+
"",
|
|
97
|
+
"Once I'm connected, we'll do a quick intro so I can learn my role and how you like to work.",
|
|
98
|
+
].join("\n");
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function reauthNudgeMessage() {
|
|
102
|
+
return [
|
|
103
|
+
"⚠️ I'm back online but not signed in to any AI provider, so I can't work on messages right now.",
|
|
104
|
+
"",
|
|
105
|
+
"Send /login to reconnect. (Claude: /setup_token + /use_oauth_token · Codex: /codex_login)",
|
|
106
|
+
].join("\n");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function interviewPrompt() {
|
|
110
|
+
return [
|
|
111
|
+
"You have just come online for the very first time and this is your first conversation with your owner. A provider is now connected, and your soul file is still the generic template.",
|
|
112
|
+
"",
|
|
113
|
+
"Run a short, warm onboarding interview (mobile chat — keep every message brief):",
|
|
114
|
+
"1. Introduce yourself in a sentence or two and say you'd like to get set up.",
|
|
115
|
+
"2. Ask what your job should be and what they'd like to call you.",
|
|
116
|
+
"3. Ask what they do and how they like to work with an assistant (style, level of detail).",
|
|
117
|
+
"Ask at most two questions per message and let their answers guide follow-ups over the next few turns.",
|
|
118
|
+
"",
|
|
119
|
+
`As their answers arrive over the coming turns, persist what you learn: update your soul file at ${SOUL_FILE} (identity, name, role/scope, owner profile, communication style — keep any existing hard rules), and record durable facts about the owner in your long-term memory.`,
|
|
120
|
+
"",
|
|
121
|
+
"For THIS message: send only the greeting and your first questions. Do not mention this prompt or any internal setup mechanics.",
|
|
122
|
+
].join("\n");
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function stopAuthWatcher() {
|
|
126
|
+
if (authWatcher) { clearInterval(authWatcher); authWatcher = null; }
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function startInterview(ownerChat) {
|
|
130
|
+
const before = readFlags();
|
|
131
|
+
if (before.interviewStartedAt) return false;
|
|
132
|
+
writeFlags({ interviewStartedAt: Date.now() });
|
|
133
|
+
stopAuthWatcher();
|
|
134
|
+
try {
|
|
135
|
+
return await runInChat(chatScope(ownerChat), async () => {
|
|
136
|
+
const { currentState, saveState } = require("./state");
|
|
137
|
+
const state = currentState();
|
|
138
|
+
if (!state.currentSession) {
|
|
139
|
+
require("./handlers").selectProjectState(
|
|
140
|
+
{ name: "Workspace", dir: WORKSPACE },
|
|
141
|
+
{ state, persist: false },
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
saveState();
|
|
145
|
+
const { admitRunContext, runAgent } = require("./runner");
|
|
146
|
+
const prompt = interviewPrompt();
|
|
147
|
+
const runContext = admitRunContext(prompt, state.currentSession?.dir || WORKSPACE, null, { purpose: "foreground" });
|
|
148
|
+
const result = await runAgent(prompt, runContext.cwd, null, { runContext });
|
|
149
|
+
if (result && result.ok === false) throw new Error(result.error?.message || "interview run did not complete");
|
|
150
|
+
// Marked only after the opening turn actually reached the owner, so a
|
|
151
|
+
// failed dispatch retries next boot instead of half-onboarding the bot.
|
|
152
|
+
saveEnvKey("ONBOARDED", "true");
|
|
153
|
+
config.ONBOARDED = "true";
|
|
154
|
+
return true;
|
|
155
|
+
});
|
|
156
|
+
} catch (e) {
|
|
157
|
+
console.error("boot-onboarding: interview run failed, will retry next boot:", e.message);
|
|
158
|
+
writeFlags({ interviewStartedAt: null });
|
|
159
|
+
return false;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function armAuthWatcher(ownerChat) {
|
|
164
|
+
stopAuthWatcher();
|
|
165
|
+
authWatcher = setInterval(() => {
|
|
166
|
+
if (isOnboarded() || readFlags().interviewStartedAt) return stopAuthWatcher();
|
|
167
|
+
if (!authenticatedProviderId()) return;
|
|
168
|
+
startInterview(ownerChat).catch((e) => console.error("boot-onboarding: interview dispatch failed:", e.message));
|
|
169
|
+
}, AUTH_POLL_MS);
|
|
170
|
+
if (typeof authWatcher.unref === "function") authWatcher.unref();
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async function initBootOnboarding(adapters) {
|
|
174
|
+
const ownerChat = await resolveOwnerChat(adapters);
|
|
175
|
+
if (!ownerChat) return;
|
|
176
|
+
|
|
177
|
+
const flags = readFlags();
|
|
178
|
+
const authedProvider = authenticatedProviderId();
|
|
179
|
+
|
|
180
|
+
if (isOnboarded()) {
|
|
181
|
+
if (authedProvider) {
|
|
182
|
+
// Healthy boot: re-arm the one-shot nudge for a future auth loss.
|
|
183
|
+
if (flags.unauthNudgedAt) writeFlags({ unauthNudgedAt: null });
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
if (!flags.unauthNudgedAt) {
|
|
187
|
+
const sent = await runInChat(chatScope(ownerChat), () => require("./io").send(reauthNudgeMessage()));
|
|
188
|
+
if (sent?.ok) writeFlags({ unauthNudgedAt: Date.now() });
|
|
189
|
+
}
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (authedProvider) {
|
|
194
|
+
await startInterview(ownerChat);
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (!flags.greetedAt) {
|
|
199
|
+
const sent = await runInChat(chatScope(ownerChat), () => require("./io").send(greetingMessage()));
|
|
200
|
+
if (sent?.ok) writeFlags({ greetedAt: Date.now() });
|
|
201
|
+
}
|
|
202
|
+
armAuthWatcher(ownerChat);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
module.exports = {
|
|
206
|
+
initBootOnboarding,
|
|
207
|
+
startInterview,
|
|
208
|
+
resolveOwnerChat,
|
|
209
|
+
readFlags,
|
|
210
|
+
FLAGS_FILE,
|
|
211
|
+
stopAuthWatcher,
|
|
212
|
+
};
|
package/core/router.js
CHANGED
|
@@ -37,6 +37,18 @@ async function deliverRouterError(error) {
|
|
|
37
37
|
const code = error?.code || "ROUTER_ERROR";
|
|
38
38
|
if (!USER_VISIBLE_PROVIDER_ERRORS.has(code)) return null;
|
|
39
39
|
const message = error?.message || "No compatible provider is available.";
|
|
40
|
+
if (code === "PROVIDER_UNAUTHENTICATED") {
|
|
41
|
+
const providerId = error?.details?.providerId;
|
|
42
|
+
await send([
|
|
43
|
+
"I'm not signed in to an AI provider yet, so I can't work on that message.",
|
|
44
|
+
"",
|
|
45
|
+
"Send /login to pick a model and connect — I'll walk you through it right here in chat.",
|
|
46
|
+
providerId === "codex"
|
|
47
|
+
? "Shortcuts: /codex_login (device auth) or /codex_setup_token (paste an OpenAI API key securely)."
|
|
48
|
+
: "Shortcuts: /setup_token then /use_oauth_token (long-lived Claude token), or /login for the guided flow.",
|
|
49
|
+
].join("\n"));
|
|
50
|
+
return { ok: false, status: "failed", error: { code, message } };
|
|
51
|
+
}
|
|
40
52
|
await send(`${message}\n\nOpen /backend to review providers, or run /doctor for local compatibility and authentication checks.`);
|
|
41
53
|
return { ok: false, status: "failed", error: { code, message } };
|
|
42
54
|
}
|
package/package.json
CHANGED
|
@@ -32,11 +32,12 @@ assert.doesNotMatch(setup, /Your Claude Code bot is connected|Description=Claude
|
|
|
32
32
|
|
|
33
33
|
const pkg = JSON.parse(read("package.json"));
|
|
34
34
|
assert.match(pkg.description, /provider-agnostic coding-agent harness/i);
|
|
35
|
-
// 3.0.
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
|
|
35
|
+
// 3.0.12 approved by Sumeet 2026-07-13 ("Yes" to the platform unification
|
|
36
|
+
// epic): dashboard auth hardening (no forced password change, login
|
|
37
|
+
// rate-limiting + lockout, constant-time compares, Secure cookie),
|
|
38
|
+
// channel-less boot with the web UI up, and live `channels` in /api/status
|
|
39
|
+
// for AgentSpace channel chips.
|
|
40
|
+
assert.strictEqual(pkg.version, "3.0.12", "release version must remain unchanged without explicit approval");
|
|
40
41
|
for (const keyword of ["claude", "codex", "coding-agent", "provider-agnostic"]) {
|
|
41
42
|
assert.ok(pkg.keywords.includes(keyword), `package keyword missing: ${keyword}`);
|
|
42
43
|
}
|
package/web.js
CHANGED
|
@@ -143,6 +143,59 @@ function checkAuth(req) {
|
|
|
143
143
|
return webSessions.valid(token);
|
|
144
144
|
}
|
|
145
145
|
|
|
146
|
+
// Hash both sides so timingSafeEqual gets equal-length buffers and the
|
|
147
|
+
// comparison leaks neither content nor password length.
|
|
148
|
+
function safeEqual(a, b) {
|
|
149
|
+
const ha = crypto.createHash("sha256").update(String(a)).digest();
|
|
150
|
+
const hb = crypto.createHash("sha256").update(String(b)).digest();
|
|
151
|
+
return crypto.timingSafeEqual(ha, hb);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Login rate limiting: 5 failures → lockout with exponential backoff
|
|
155
|
+
// (30s, 60s, ... capped at 15min). Keyed by client IP (first XFF hop
|
|
156
|
+
// when behind the ingress). In-memory — resets on restart, which is fine.
|
|
157
|
+
const loginFailures = new Map();
|
|
158
|
+
const LOCKOUT_THRESHOLD = 5;
|
|
159
|
+
const LOCKOUT_BASE_MS = 30 * 1000;
|
|
160
|
+
const LOCKOUT_MAX_MS = 15 * 60 * 1000;
|
|
161
|
+
|
|
162
|
+
function clientKey(req) {
|
|
163
|
+
const xff = String(req.headers["x-forwarded-for"] || "").split(",")[0].trim();
|
|
164
|
+
return xff || req.socket?.remoteAddress || "unknown";
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function loginLockedMs(req) {
|
|
168
|
+
const rec = loginFailures.get(clientKey(req));
|
|
169
|
+
if (!rec || !rec.lockedUntil) return 0;
|
|
170
|
+
const left = rec.lockedUntil - Date.now();
|
|
171
|
+
return left > 0 ? left : 0;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function recordLoginFailure(req) {
|
|
175
|
+
const key = clientKey(req);
|
|
176
|
+
const rec = loginFailures.get(key) || { count: 0, lockouts: 0, lockedUntil: 0 };
|
|
177
|
+
rec.count += 1;
|
|
178
|
+
if (rec.count >= LOCKOUT_THRESHOLD) {
|
|
179
|
+
rec.count = 0;
|
|
180
|
+
const wait = Math.min(LOCKOUT_BASE_MS * 2 ** rec.lockouts, LOCKOUT_MAX_MS);
|
|
181
|
+
rec.lockouts += 1;
|
|
182
|
+
rec.lockedUntil = Date.now() + wait;
|
|
183
|
+
}
|
|
184
|
+
loginFailures.set(key, rec);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function clearLoginFailures(req) {
|
|
188
|
+
loginFailures.delete(clientKey(req));
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Secure flag only when the client actually arrived over HTTPS (ingress
|
|
192
|
+
// sets X-Forwarded-Proto) — keeps plain-HTTP port-forward logins working.
|
|
193
|
+
function sessionCookie(req) {
|
|
194
|
+
const proto = String(req.headers["x-forwarded-proto"] || "").split(",")[0].trim();
|
|
195
|
+
const secure = proto === "https" ? "; Secure" : "";
|
|
196
|
+
return `oc_session=${webSessions.issue()}; Path=/; HttpOnly; SameSite=Strict; Max-Age=86400${secure}`;
|
|
197
|
+
}
|
|
198
|
+
|
|
146
199
|
// ── Config helpers ─────────────────────────────────────────────────
|
|
147
200
|
|
|
148
201
|
function loadEnv() {
|
|
@@ -289,6 +342,32 @@ function telegramGet(token, method) {
|
|
|
289
342
|
});
|
|
290
343
|
}
|
|
291
344
|
|
|
345
|
+
// Live channel list from the in-process adapter registry — the source of
|
|
346
|
+
// truth for what this pod is actually connected to (provisioning-time env
|
|
347
|
+
// goes stale once channels are attached/removed at runtime).
|
|
348
|
+
let tgUsernameCache = null;
|
|
349
|
+
async function liveChannels() {
|
|
350
|
+
let adapters = [];
|
|
351
|
+
try { adapters = require("./core/adapter-registry").getAdapters(); } catch (e) { return []; }
|
|
352
|
+
const out = [];
|
|
353
|
+
for (const a of adapters) {
|
|
354
|
+
const ch = { id: a.id, type: a.type };
|
|
355
|
+
if (a.type === "telegram") {
|
|
356
|
+
const token = loadEnv().TELEGRAM_BOT_TOKEN || process.env.TELEGRAM_BOT_TOKEN;
|
|
357
|
+
if (token) {
|
|
358
|
+
if (!tgUsernameCache) {
|
|
359
|
+
const me = await telegramGet(token, "getMe");
|
|
360
|
+
if (me && me.ok) tgUsernameCache = (me.result && me.result.username) || null;
|
|
361
|
+
}
|
|
362
|
+
if (tgUsernameCache) ch.username = tgUsernameCache;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
if (a.type === "kazee" && a.botUserId) ch.botUserId = String(a.botUserId);
|
|
366
|
+
out.push(ch);
|
|
367
|
+
}
|
|
368
|
+
return out;
|
|
369
|
+
}
|
|
370
|
+
|
|
292
371
|
// ── API routes ─────────────────────────────────────────────────────
|
|
293
372
|
|
|
294
373
|
async function handleAPI(req, res, body, dependencies = {}) {
|
|
@@ -296,14 +375,23 @@ async function handleAPI(req, res, body, dependencies = {}) {
|
|
|
296
375
|
|
|
297
376
|
// Login — no auth required
|
|
298
377
|
if (url === "/api/login" && req.method === "POST") {
|
|
299
|
-
const
|
|
300
|
-
if (
|
|
378
|
+
const lockedMs = loginLockedMs(req);
|
|
379
|
+
if (lockedMs > 0) {
|
|
380
|
+
const seconds = Math.ceil(lockedMs / 1000);
|
|
381
|
+
res.writeHead(429, { "Content-Type": "application/json", "Retry-After": String(seconds) });
|
|
382
|
+
return res.end(JSON.stringify({ ok: false, error: `Too many attempts — try again in ${seconds}s` }));
|
|
383
|
+
}
|
|
384
|
+
let password;
|
|
385
|
+
try { ({ password } = JSON.parse(body)); } catch (e) { /* fall through to failure */ }
|
|
386
|
+
if (typeof password === "string" && safeEqual(password, getPassword())) {
|
|
387
|
+
clearLoginFailures(req);
|
|
301
388
|
res.writeHead(200, {
|
|
302
389
|
"Content-Type": "application/json",
|
|
303
|
-
"Set-Cookie":
|
|
390
|
+
"Set-Cookie": sessionCookie(req),
|
|
304
391
|
});
|
|
305
|
-
return res.end(JSON.stringify({ ok: true
|
|
392
|
+
return res.end(JSON.stringify({ ok: true }));
|
|
306
393
|
}
|
|
394
|
+
recordLoginFailure(req);
|
|
307
395
|
res.writeHead(401, { "Content-Type": "application/json" });
|
|
308
396
|
return res.end(JSON.stringify({ ok: false, error: "Wrong password" }));
|
|
309
397
|
}
|
|
@@ -347,6 +435,7 @@ async function handleAPI(req, res, body, dependencies = {}) {
|
|
|
347
435
|
providers: providerStatus.providers,
|
|
348
436
|
defaultProvider: providerStatus.defaultProvider,
|
|
349
437
|
onboarded: env.ONBOARDED === "true",
|
|
438
|
+
channels: await liveChannels(),
|
|
350
439
|
}));
|
|
351
440
|
}
|
|
352
441
|
|
|
@@ -575,10 +664,10 @@ async function handleAPI(req, res, body, dependencies = {}) {
|
|
|
575
664
|
}));
|
|
576
665
|
}
|
|
577
666
|
|
|
578
|
-
// Change password
|
|
667
|
+
// Change password (voluntary — never forced)
|
|
579
668
|
if (url === "/api/password" && req.method === "POST") {
|
|
580
669
|
const { current, newPassword } = JSON.parse(body);
|
|
581
|
-
if (current !== getPassword()) {
|
|
670
|
+
if (typeof current !== "string" || !safeEqual(current, getPassword())) {
|
|
582
671
|
res.writeHead(400, { "Content-Type": "application/json" });
|
|
583
672
|
return res.end(JSON.stringify({ error: "Wrong current password" }));
|
|
584
673
|
}
|
|
@@ -593,7 +682,7 @@ async function handleAPI(req, res, body, dependencies = {}) {
|
|
|
593
682
|
webSessions.revokeAll();
|
|
594
683
|
res.writeHead(200, {
|
|
595
684
|
"Content-Type": "application/json",
|
|
596
|
-
"Set-Cookie":
|
|
685
|
+
"Set-Cookie": sessionCookie(req),
|
|
597
686
|
});
|
|
598
687
|
return res.end(JSON.stringify({ ok: true }));
|
|
599
688
|
}
|
|
@@ -718,38 +807,8 @@ function showLogin() {
|
|
|
718
807
|
async function doLogin() {
|
|
719
808
|
const pw = $("#pw").value;
|
|
720
809
|
const r = await api("/api/login", { method: "POST", body: { password: pw } });
|
|
721
|
-
if (r?.ok
|
|
722
|
-
else
|
|
723
|
-
else $("#login-msg").innerHTML = '<div class="msg err">Wrong password</div>';
|
|
724
|
-
}
|
|
725
|
-
|
|
726
|
-
function showForceChangePassword(currentPw) {
|
|
727
|
-
$("#app").innerHTML = \`
|
|
728
|
-
<div class="login-page"><div class="login-box">
|
|
729
|
-
<h1>Change Password</h1>
|
|
730
|
-
<p class="subtitle">You must set a secure password before continuing</p>
|
|
731
|
-
<div id="change-msg"></div>
|
|
732
|
-
<div class="card">
|
|
733
|
-
<div class="form-group">
|
|
734
|
-
<input type="password" id="new-pw-1" placeholder="New password">
|
|
735
|
-
</div>
|
|
736
|
-
<div class="form-group">
|
|
737
|
-
<input type="password" id="new-pw-2" placeholder="Confirm password" onkeydown="if(event.key==='Enter')doForceChange()">
|
|
738
|
-
</div>
|
|
739
|
-
<p style="font-size:11px;color:#888;margin:8px 0">Min 12 chars, uppercase, lowercase, number, and symbol</p>
|
|
740
|
-
<button onclick="doForceChange()" style="width:100%">Set Password</button>
|
|
741
|
-
</div>
|
|
742
|
-
</div></div>\`;
|
|
743
|
-
window._forceChangePw = currentPw;
|
|
744
|
-
setTimeout(() => $("#new-pw-1")?.focus(), 100);
|
|
745
|
-
}
|
|
746
|
-
|
|
747
|
-
async function doForceChange() {
|
|
748
|
-
const p1 = $("#new-pw-1").value, p2 = $("#new-pw-2").value;
|
|
749
|
-
if (p1 !== p2) { $("#change-msg").innerHTML = '<div class="msg err">Passwords do not match</div>'; return; }
|
|
750
|
-
const r = await api("/api/password", { method: "POST", body: { current: window._forceChangePw, newPassword: p1 } });
|
|
751
|
-
if (r?.ok) { delete window._forceChangePw; init(); }
|
|
752
|
-
else $("#change-msg").innerHTML = '<div class="msg err">' + (r?.error || "Failed") + '</div>';
|
|
810
|
+
if (r?.ok) init();
|
|
811
|
+
else $("#login-msg").innerHTML = '<div class="msg err">' + (r?.error || "Wrong password") + '</div>';
|
|
753
812
|
}
|
|
754
813
|
|
|
755
814
|
function render() {
|
|
@@ -1248,7 +1307,7 @@ function startWebServer(options = {}) {
|
|
|
1248
1307
|
if (consume(token)) {
|
|
1249
1308
|
res.writeHead(302, {
|
|
1250
1309
|
"Location": "/",
|
|
1251
|
-
"Set-Cookie":
|
|
1310
|
+
"Set-Cookie": sessionCookie(req),
|
|
1252
1311
|
});
|
|
1253
1312
|
return res.end();
|
|
1254
1313
|
}
|