@inetafrica/open-claudia 1.20.0 → 2.0.0
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/.env.example +15 -1
- package/CHANGELOG.md +15 -0
- package/Dockerfile +3 -0
- package/README.md +2 -1
- package/bot.js +109 -3440
- package/channels/kazee/adapter.js +375 -0
- package/channels/kazee/format.js +9 -0
- package/channels/kazee/socket.js +28 -0
- package/channels/telegram/adapter.js +296 -0
- package/channels/telegram/format.js +12 -0
- package/channels/types.js +91 -0
- package/core/access.js +147 -0
- package/core/actions.js +180 -0
- package/core/adapter-registry.js +113 -0
- package/core/auth-flow.js +433 -0
- package/core/channel-wizard.js +174 -0
- package/core/commands.js +73 -0
- package/core/config.js +197 -0
- package/core/context.js +44 -0
- package/core/cron.js +77 -0
- package/core/doctor.js +126 -0
- package/core/handlers.js +995 -0
- package/core/identity.js +87 -0
- package/core/io.js +59 -0
- package/core/kazee-probe.js +48 -0
- package/core/media.js +39 -0
- package/core/onboarding.js +97 -0
- package/core/process-tree.js +40 -0
- package/core/projects.js +43 -0
- package/core/redact.js +26 -0
- package/core/router.js +309 -0
- package/core/runner.js +683 -0
- package/core/state.js +261 -0
- package/core/system-prompt.js +66 -0
- package/core/transcripts.js +71 -0
- package/core/vault-store.js +9 -0
- package/docker-entrypoint.sh +21 -4
- package/package.json +6 -3
- package/project-transcripts.js +17 -12
- package/setup.js +44 -3
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
// Claude/Codex login state machines and helpers. The actual handler
|
|
2
|
+
// commands (/login, /setup_token, /codex_login, …) live in
|
|
3
|
+
// core/handlers; this module owns the heavy lifting they call into.
|
|
4
|
+
|
|
5
|
+
const fs = require("fs");
|
|
6
|
+
const { spawn, execSync } = require("child_process");
|
|
7
|
+
const {
|
|
8
|
+
config, saveEnvKey,
|
|
9
|
+
CLAUDE_PATH, resolvedCodexPath,
|
|
10
|
+
botSubprocessEnv,
|
|
11
|
+
} = require("./config");
|
|
12
|
+
const { vault } = require("./vault-store");
|
|
13
|
+
const { redactSensitive, stripTerminalControls, extractUrls } = require("./redact");
|
|
14
|
+
const { send } = require("./io");
|
|
15
|
+
const { currentState } = require("./state");
|
|
16
|
+
|
|
17
|
+
const CLAUDE_OAUTH_TOKEN_KEY = "CLAUDE_CODE_OAUTH_TOKEN";
|
|
18
|
+
const CLAUDE_OAUTH_VAULT_KEY = "claude_oauth_token";
|
|
19
|
+
|
|
20
|
+
function getClaudeOAuthToken() {
|
|
21
|
+
if (config[CLAUDE_OAUTH_TOKEN_KEY]) return { value: config[CLAUDE_OAUTH_TOKEN_KEY], source: ".env" };
|
|
22
|
+
if (process.env.CLAUDE_CODE_OAUTH_TOKEN) return { value: process.env.CLAUDE_CODE_OAUTH_TOKEN, source: "process env" };
|
|
23
|
+
if (vault.isUnlocked()) {
|
|
24
|
+
const value = vault.get(CLAUDE_OAUTH_VAULT_KEY) || vault.get(CLAUDE_OAUTH_TOKEN_KEY);
|
|
25
|
+
if (value) return { value, source: "vault" };
|
|
26
|
+
}
|
|
27
|
+
return { value: null, source: null };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function claudeSubprocessEnv() {
|
|
31
|
+
const env = botSubprocessEnv();
|
|
32
|
+
const token = getClaudeOAuthToken().value;
|
|
33
|
+
if (token) env.CLAUDE_CODE_OAUTH_TOKEN = token;
|
|
34
|
+
return env;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function saveClaudeOAuthToken(token) {
|
|
38
|
+
const clean = String(token || "").trim();
|
|
39
|
+
if (!clean) return false;
|
|
40
|
+
saveEnvKey(CLAUDE_OAUTH_TOKEN_KEY, clean);
|
|
41
|
+
config[CLAUDE_OAUTH_TOKEN_KEY] = clean;
|
|
42
|
+
process.env.CLAUDE_CODE_OAUTH_TOKEN = clean;
|
|
43
|
+
if (vault.isUnlocked()) vault.set(CLAUDE_OAUTH_VAULT_KEY, clean);
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function clearClaudeOAuthToken() {
|
|
48
|
+
saveEnvKey(CLAUDE_OAUTH_TOKEN_KEY, "");
|
|
49
|
+
config[CLAUDE_OAUTH_TOKEN_KEY] = "";
|
|
50
|
+
delete process.env.CLAUDE_CODE_OAUTH_TOKEN;
|
|
51
|
+
if (vault.isUnlocked()) {
|
|
52
|
+
vault.remove(CLAUDE_OAUTH_VAULT_KEY);
|
|
53
|
+
vault.remove(CLAUDE_OAUTH_TOKEN_KEY);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function extractClaudeToken(text) {
|
|
58
|
+
const match = String(text || "").match(/sk-ant-[A-Za-z0-9._-]+/);
|
|
59
|
+
return match ? match[0] : null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function looksLikeClaudeToken(value) {
|
|
63
|
+
const text = String(value || "").trim();
|
|
64
|
+
return /^sk-ant-[A-Za-z0-9._-]+$/.test(text) || (text.length >= 80 && /^[A-Za-z0-9._=-]+$/.test(text));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function looksLikeClaudeAuthReply(value) {
|
|
68
|
+
const text = String(value || "").trim();
|
|
69
|
+
if (!text) return false;
|
|
70
|
+
if (looksLikeClaudeToken(text)) return true;
|
|
71
|
+
if (/^https?:\/\//i.test(text) && /claude|anthropic/i.test(text)) return true;
|
|
72
|
+
if (text.length >= 24 && !/\s/.test(text) && /^[A-Za-z0-9._~:/?#[\]@!$&'()*+,;=%-]+$/.test(text)) return true;
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function isClaudeAuthUrl(url) {
|
|
77
|
+
try {
|
|
78
|
+
const u = new URL(url);
|
|
79
|
+
const host = u.hostname.toLowerCase();
|
|
80
|
+
return host === "claude.com" || host.endsWith(".claude.com") ||
|
|
81
|
+
host === "anthropic.com" || host.endsWith(".anthropic.com") ||
|
|
82
|
+
host === "localhost" || host === "127.0.0.1";
|
|
83
|
+
} catch (e) {
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function looksLikeOpenAIKey(value) {
|
|
89
|
+
return /^sk-(?:proj-)?[A-Za-z0-9._-]{20,}$/.test(String(value || "").trim());
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function isClaudeAuthErrorText(text) {
|
|
93
|
+
const lower = String(text || "").toLowerCase();
|
|
94
|
+
return lower.includes("unauthorized") ||
|
|
95
|
+
lower.includes("not logged in") ||
|
|
96
|
+
lower.includes("login required") ||
|
|
97
|
+
lower.includes("reauthenticate") ||
|
|
98
|
+
lower.includes("re-authenticate") ||
|
|
99
|
+
lower.includes("authentication failed") ||
|
|
100
|
+
lower.includes("auth failed") ||
|
|
101
|
+
lower.includes("invalid api key") ||
|
|
102
|
+
(lower.includes("api key") && lower.includes("invalid")) ||
|
|
103
|
+
(lower.includes("keychain") && (lower.includes("unlock") || lower.includes("could not") || lower.includes("failed"))) ||
|
|
104
|
+
lower.includes("security unlock-keychain");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function isClaudeUsageLimitText(text) {
|
|
108
|
+
const lower = String(text || "").toLowerCase();
|
|
109
|
+
return lower.includes("usage limit") ||
|
|
110
|
+
lower.includes("you've hit your usage limit") ||
|
|
111
|
+
lower.includes("you have hit your usage limit") ||
|
|
112
|
+
lower.includes("spend limit") ||
|
|
113
|
+
lower.includes("monthly cycle") ||
|
|
114
|
+
(lower.includes("rate limit") && lower.includes("model"));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function isCodexAuthErrorText(text) {
|
|
118
|
+
return /not (?:logged in|authenticated)|unauthenticated|login required|please (?:log|sign) in|401 unauthorized|invalid api key|no credentials/i.test(String(text || ""));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function summarizeAuthOutput(output, ok) {
|
|
122
|
+
const clean = redactSensitive(stripTerminalControls(output || "")).replace(/\s+/g, " ").trim();
|
|
123
|
+
if (!clean) return ok ? "ok" : "no output";
|
|
124
|
+
return clean.length > 160 ? clean.slice(0, 157) + "..." : clean;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function summarizeClaudeAuthStatus(output, exitCode, tokenInfo) {
|
|
128
|
+
const text = String(output || "");
|
|
129
|
+
const lower = text.toLowerCase();
|
|
130
|
+
const loggedOut = /not (logged in|authenticated)|unauthenticated|no auth|login required/.test(lower);
|
|
131
|
+
const loggedIn = !loggedOut && (exitCode === 0 || /logged in|authenticated|claude\.ai|anthropic/.test(lower));
|
|
132
|
+
const email = (text.match(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/i) || [null])[0];
|
|
133
|
+
const provider = /claude\.ai|claudeai/.test(lower) ? "Claude.ai" : (/anthropic/.test(lower) ? "Anthropic" : "unknown");
|
|
134
|
+
let method = tokenInfo.value ? `OAuth token (${tokenInfo.source})` : "unknown";
|
|
135
|
+
if (/api key|apikey/.test(lower)) method = "API key";
|
|
136
|
+
else if (/oauth|claude\.ai|claudeai/.test(lower)) method = tokenInfo.value ? `OAuth token (${tokenInfo.source})` : "OAuth/Claude.ai";
|
|
137
|
+
return [
|
|
138
|
+
`Logged in: ${loggedIn ? "yes" : (loggedOut ? "no" : "unknown")}`,
|
|
139
|
+
`Auth method: ${method}`,
|
|
140
|
+
`Email: ${email || "unknown"}`,
|
|
141
|
+
`Provider: ${provider}`,
|
|
142
|
+
];
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function claudeAuthRecoveryMessage(reason = "Claude Code authentication failed") {
|
|
146
|
+
const tokenInfo = getClaudeOAuthToken();
|
|
147
|
+
const tokenLine = tokenInfo.value
|
|
148
|
+
? `Stored OAuth token: yes (${tokenInfo.source})`
|
|
149
|
+
: "Stored OAuth token: no";
|
|
150
|
+
return [
|
|
151
|
+
`Claude auth needs attention: ${redactSensitive(reason)}`,
|
|
152
|
+
"",
|
|
153
|
+
tokenLine,
|
|
154
|
+
"",
|
|
155
|
+
"Try one of these from chat:",
|
|
156
|
+
"1. /auth_status — check what Claude sees",
|
|
157
|
+
"2. /setup_token — create a long-lived Claude Code token, then store it",
|
|
158
|
+
"3. /use_oauth_token — paste the token securely if you already generated one",
|
|
159
|
+
"4. /login — interactive Claude.ai browser login fallback",
|
|
160
|
+
"",
|
|
161
|
+
"If this is a macOS Keychain problem after SSH/reboot, run on the Mac:",
|
|
162
|
+
"security unlock-keychain",
|
|
163
|
+
"",
|
|
164
|
+
"Recommended for this bot: /setup_token + /use_oauth_token so launchd does not depend on Keychain.",
|
|
165
|
+
].join("\n");
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function claudeUsageLimitMessage(details = "") {
|
|
169
|
+
return [
|
|
170
|
+
"Claude ran, but the selected model is unavailable/limited right now.",
|
|
171
|
+
details ? `\nDetails:\n${redactSensitive(details)}` : "",
|
|
172
|
+
"",
|
|
173
|
+
"Try from chat:",
|
|
174
|
+
"1. /model sonnet",
|
|
175
|
+
"2. Then send your message again",
|
|
176
|
+
"",
|
|
177
|
+
"If you specifically need Opus, wait for the usage window to reset or increase the spend/usage limit.",
|
|
178
|
+
].filter(Boolean).join("\n");
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function runClaudeAuthStatusDiagnostic() {
|
|
182
|
+
try {
|
|
183
|
+
const output = execSync(`"${CLAUDE_PATH}" auth status`, {
|
|
184
|
+
cwd: process.env.HOME || require("os").homedir(),
|
|
185
|
+
env: claudeSubprocessEnv(),
|
|
186
|
+
encoding: "utf8",
|
|
187
|
+
timeout: 10000,
|
|
188
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
189
|
+
});
|
|
190
|
+
return output.trim();
|
|
191
|
+
} catch (e) {
|
|
192
|
+
return `${e.stdout || ""}\n${e.stderr || ""}\n${e.message || ""}`.trim();
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async function sendClaudeAuthStatusSummary(prefix = "Claude auth status") {
|
|
197
|
+
const output = runClaudeAuthStatusDiagnostic();
|
|
198
|
+
const tokenInfo = getClaudeOAuthToken();
|
|
199
|
+
const lines = summarizeClaudeAuthStatus(output, isClaudeAuthErrorText(output) ? 1 : 0, tokenInfo);
|
|
200
|
+
await send([
|
|
201
|
+
prefix,
|
|
202
|
+
"",
|
|
203
|
+
...lines,
|
|
204
|
+
`Bot OAuth token: ${tokenInfo.value ? "configured via " + tokenInfo.source : "not configured"}`,
|
|
205
|
+
].join("\n"));
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function clearPendingClaudeAuth(state = currentState()) {
|
|
209
|
+
if (state.pendingClaudeAuthProcess && state.pendingClaudeAuthProcess.kill) {
|
|
210
|
+
try { state.pendingClaudeAuthProcess.kill("SIGTERM"); } catch (e) {}
|
|
211
|
+
}
|
|
212
|
+
state.pendingClaudeAuthProcess = null;
|
|
213
|
+
state.pendingClaudeAuthLabel = null;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
async function runClaudeAuthCommand(args, label, opts = {}) {
|
|
217
|
+
const state = currentState();
|
|
218
|
+
if (state.pendingClaudeAuthProcess) {
|
|
219
|
+
await send(`Another Claude auth flow is already running (${state.pendingClaudeAuthLabel}). Send the requested code/token or wait for it to finish.`);
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
await send(`${label} started...`);
|
|
223
|
+
const proc = spawn(CLAUDE_PATH, args, {
|
|
224
|
+
cwd: process.env.HOME || require("os").homedir(),
|
|
225
|
+
env: claudeSubprocessEnv(),
|
|
226
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
227
|
+
});
|
|
228
|
+
state.pendingClaudeAuthProcess = proc;
|
|
229
|
+
state.pendingClaudeAuthLabel = label;
|
|
230
|
+
let output = "";
|
|
231
|
+
let sentUrls = new Set();
|
|
232
|
+
let tokenStored = false;
|
|
233
|
+
let lastSnippetAt = 0;
|
|
234
|
+
|
|
235
|
+
const handleChunk = async (chunk) => {
|
|
236
|
+
output += chunk;
|
|
237
|
+
const token = opts.captureToken ? extractClaudeToken(chunk) || extractClaudeToken(output) : null;
|
|
238
|
+
if (token && !tokenStored) {
|
|
239
|
+
tokenStored = saveClaudeOAuthToken(token);
|
|
240
|
+
await send(tokenStored ? "Claude OAuth token captured and stored. I did not print it." : "Claude OAuth token appeared, but I could not store it.");
|
|
241
|
+
}
|
|
242
|
+
for (const url of extractUrls(chunk)) {
|
|
243
|
+
if (!isClaudeAuthUrl(url)) continue;
|
|
244
|
+
if (!sentUrls.has(url)) {
|
|
245
|
+
sentUrls.add(url);
|
|
246
|
+
await send(`${label} URL:\n${redactSensitive(url)}\n\nOpen it, complete the flow, then paste any code Claude asks for here.`);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
const redacted = redactSensitive(stripTerminalControls(chunk)).trim();
|
|
250
|
+
const now = Date.now();
|
|
251
|
+
const usefulAuthLine = /opening browser|paste code|enter code|login|auth|token|error|failed/i.test(redacted);
|
|
252
|
+
if (redacted && usefulAuthLine && now - lastSnippetAt > 3000 && !looksLikeClaudeToken(redacted) && !/github\.com\/vadimdemedes\/ink/i.test(redacted)) {
|
|
253
|
+
lastSnippetAt = now;
|
|
254
|
+
await send(redacted.length > 1200 ? redacted.slice(-1200) : redacted);
|
|
255
|
+
}
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
proc.stdout.on("data", (d) => handleChunk(d.toString()).catch((e) => console.error("Auth output error:", e.message)));
|
|
259
|
+
proc.stderr.on("data", (d) => handleChunk(d.toString()).catch((e) => console.error("Auth stderr error:", e.message)));
|
|
260
|
+
proc.on("close", async (code) => {
|
|
261
|
+
state.pendingClaudeAuthProcess = null;
|
|
262
|
+
state.pendingClaudeAuthLabel = null;
|
|
263
|
+
const token = opts.captureToken ? extractClaudeToken(output) : null;
|
|
264
|
+
if (token && !tokenStored) tokenStored = saveClaudeOAuthToken(token);
|
|
265
|
+
const cleaned = redactSensitive(stripTerminalControls(output))
|
|
266
|
+
.replace(/https?:\/\/github\.com\/vadimdemedes\/ink\S*/gi, "")
|
|
267
|
+
.trim();
|
|
268
|
+
const isTtyError = /raw mode is not supported|process\.stdin/i.test(output);
|
|
269
|
+
if (tokenStored) {
|
|
270
|
+
await send(`${label} finished. OAuth token stored for launchd/non-interactive Claude runs.`);
|
|
271
|
+
await sendClaudeAuthStatusSummary("Post-auth check:");
|
|
272
|
+
} else if (isTtyError && opts.captureToken) {
|
|
273
|
+
await send(`${label} cannot complete from this bot — the Claude CLI needs an interactive terminal for setup-token.\n\nRun this in your Mac terminal:\n claude setup-token\n\nThen paste the token here with:\n /use_oauth_token <token>`);
|
|
274
|
+
} else if (cleaned) {
|
|
275
|
+
await send(`${label} finished (exit ${code}).\n\n${cleaned.slice(-2500)}`);
|
|
276
|
+
await sendClaudeAuthStatusSummary("Post-auth check:");
|
|
277
|
+
} else {
|
|
278
|
+
await send(`${label} finished (exit ${code}).`);
|
|
279
|
+
await sendClaudeAuthStatusSummary("Post-auth check:");
|
|
280
|
+
}
|
|
281
|
+
});
|
|
282
|
+
proc.on("error", async (err) => {
|
|
283
|
+
state.pendingClaudeAuthProcess = null;
|
|
284
|
+
state.pendingClaudeAuthLabel = null;
|
|
285
|
+
await send(`${label} failed: ${redactSensitive(err.message)}`);
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// ── Codex ──────────────────────────────────────────────────────────
|
|
290
|
+
|
|
291
|
+
function clearPendingCodexAuth(state = currentState()) {
|
|
292
|
+
if (state.pendingCodexAuthProcess && state.pendingCodexAuthProcess.kill) {
|
|
293
|
+
try { state.pendingCodexAuthProcess.kill("SIGTERM"); } catch (e) {}
|
|
294
|
+
}
|
|
295
|
+
state.pendingCodexAuthProcess = null;
|
|
296
|
+
state.pendingCodexAuthLabel = null;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function runCommand(command, args = [], opts = {}) {
|
|
300
|
+
try {
|
|
301
|
+
const out = execSync([command, ...args].map((v) => `"${String(v).replace(/"/g, '\\"')}"`).join(" "), {
|
|
302
|
+
cwd: opts.cwd || process.env.HOME || require("os").homedir(),
|
|
303
|
+
env: opts.env || botSubprocessEnv(),
|
|
304
|
+
encoding: "utf-8",
|
|
305
|
+
timeout: opts.timeout || 10000,
|
|
306
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
307
|
+
});
|
|
308
|
+
return { ok: true, output: out.trim(), code: 0 };
|
|
309
|
+
} catch (e) {
|
|
310
|
+
return { ok: false, output: `${e.stdout || ""}\n${e.stderr || ""}\n${e.message || ""}`.trim(), code: e.status ?? 1 };
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function runCodexLoginStatus() {
|
|
315
|
+
if (!resolvedCodexPath) return { ok: false, code: 1, output: "Codex CLI not found" };
|
|
316
|
+
const result = runCommand(resolvedCodexPath, ["login", "status"], { timeout: 12000 });
|
|
317
|
+
return { ...result, ok: result.ok && !isCodexAuthErrorText(result.output) };
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
async function sendCodexAuthStatusSummary(prefix = "Codex auth status") {
|
|
321
|
+
const status = runCodexLoginStatus();
|
|
322
|
+
const version = resolvedCodexPath ? runCommand(resolvedCodexPath, ["--version"]) : { ok: false, output: "not found" };
|
|
323
|
+
await send([
|
|
324
|
+
prefix,
|
|
325
|
+
"",
|
|
326
|
+
`CLI: ${resolvedCodexPath ? "found" : "not found"}`,
|
|
327
|
+
`Version: ${summarizeAuthOutput(version.output, version.ok)}`,
|
|
328
|
+
`Logged in: ${status.ok ? "yes" : "no/unknown"}`,
|
|
329
|
+
`Status: ${summarizeAuthOutput(status.output, status.ok)}`,
|
|
330
|
+
status.ok ? "" : "Next: /codex_login for device auth, or /codex_setup_token to paste an OpenAI API key securely.",
|
|
331
|
+
].filter(Boolean).join("\n"));
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
async function saveCodexApiKeyWithCli(apiKey) {
|
|
335
|
+
return new Promise((resolve) => {
|
|
336
|
+
const proc = spawn(resolvedCodexPath, ["login", "--with-api-key"], {
|
|
337
|
+
cwd: process.env.HOME || require("os").homedir(),
|
|
338
|
+
env: botSubprocessEnv(),
|
|
339
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
340
|
+
});
|
|
341
|
+
let output = "";
|
|
342
|
+
proc.stdout.on("data", (d) => { output += d.toString(); });
|
|
343
|
+
proc.stderr.on("data", (d) => { output += d.toString(); });
|
|
344
|
+
proc.on("close", (code) => resolve({ ok: code === 0, code, output }));
|
|
345
|
+
proc.on("error", (err) => resolve({ ok: false, code: 1, output: err.message }));
|
|
346
|
+
proc.stdin.write(apiKey.trim() + "\n");
|
|
347
|
+
proc.stdin.end();
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
async function runCodexDeviceLogin() {
|
|
352
|
+
const state = currentState();
|
|
353
|
+
if (!resolvedCodexPath) return send("Codex CLI not found. Install: npm install -g @openai/codex");
|
|
354
|
+
if (state.pendingCodexAuthProcess) return send(`Another Codex auth flow is already running (${state.pendingCodexAuthLabel}). Send /cancel_codex_auth to cancel.`);
|
|
355
|
+
await send("Codex device login started. I'll send the URL/code if the CLI prints one.");
|
|
356
|
+
const proc = spawn(resolvedCodexPath, ["login", "--device-auth"], {
|
|
357
|
+
cwd: process.env.HOME || require("os").homedir(),
|
|
358
|
+
env: botSubprocessEnv(),
|
|
359
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
360
|
+
});
|
|
361
|
+
state.pendingCodexAuthProcess = proc;
|
|
362
|
+
state.pendingCodexAuthLabel = "Codex device login";
|
|
363
|
+
let output = "";
|
|
364
|
+
let sent = new Set();
|
|
365
|
+
let lastSnippetAt = 0;
|
|
366
|
+
const handleChunk = async (chunk) => {
|
|
367
|
+
output += chunk;
|
|
368
|
+
const cleanChunk = redactSensitive(stripTerminalControls(chunk));
|
|
369
|
+
for (const url of extractUrls(cleanChunk)) {
|
|
370
|
+
if (!sent.has(url)) {
|
|
371
|
+
sent.add(url);
|
|
372
|
+
await send(`Codex login URL:\n${redactSensitive(url)}\n\nOpen it and enter the device code if shown.`);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
const codeMatch = cleanChunk.match(/(?:code|device code)[:\s]+([A-Z0-9-]{6,})/i);
|
|
376
|
+
if (codeMatch && !sent.has(codeMatch[1])) {
|
|
377
|
+
sent.add(codeMatch[1]);
|
|
378
|
+
await send(`Codex device code: ${codeMatch[1]}`);
|
|
379
|
+
}
|
|
380
|
+
const now = Date.now();
|
|
381
|
+
if (cleanChunk.trim() && /device|code|login|browser|open|auth|token|error|failed|api key/i.test(cleanChunk) && now - lastSnippetAt > 3000) {
|
|
382
|
+
lastSnippetAt = now;
|
|
383
|
+
await send(cleanChunk.trim().slice(-1200));
|
|
384
|
+
}
|
|
385
|
+
};
|
|
386
|
+
proc.stdout.on("data", (d) => handleChunk(d.toString()).catch((e) => console.error("Codex auth output error:", e.message)));
|
|
387
|
+
proc.stderr.on("data", (d) => handleChunk(d.toString()).catch((e) => console.error("Codex auth stderr error:", e.message)));
|
|
388
|
+
proc.on("close", async (code) => {
|
|
389
|
+
state.pendingCodexAuthProcess = null;
|
|
390
|
+
state.pendingCodexAuthLabel = null;
|
|
391
|
+
const clean = redactSensitive(stripTerminalControls(output)).trim();
|
|
392
|
+
if (/raw mode is not supported|not a tty|inappropriate ioctl/i.test(clean)) {
|
|
393
|
+
await send("Codex device login could not complete inside the bot on this host. Use /codex_setup_token to paste an OpenAI API key securely, or run `codex login --device-auth` in an SSH/terminal session.");
|
|
394
|
+
} else if (clean) await send(`Codex login finished (exit ${code}).\n\n${clean.slice(-2000)}`);
|
|
395
|
+
else await send(`Codex login finished (exit ${code}).`);
|
|
396
|
+
await sendCodexAuthStatusSummary("Post-Codex-auth check:");
|
|
397
|
+
});
|
|
398
|
+
proc.on("error", async (err) => {
|
|
399
|
+
state.pendingCodexAuthProcess = null;
|
|
400
|
+
state.pendingCodexAuthLabel = null;
|
|
401
|
+
await send(`Codex login failed: ${redactSensitive(err.message)}`);
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
module.exports = {
|
|
406
|
+
CLAUDE_OAUTH_TOKEN_KEY, CLAUDE_OAUTH_VAULT_KEY,
|
|
407
|
+
getClaudeOAuthToken,
|
|
408
|
+
claudeSubprocessEnv,
|
|
409
|
+
saveClaudeOAuthToken,
|
|
410
|
+
clearClaudeOAuthToken,
|
|
411
|
+
extractClaudeToken,
|
|
412
|
+
looksLikeClaudeToken,
|
|
413
|
+
looksLikeClaudeAuthReply,
|
|
414
|
+
isClaudeAuthUrl,
|
|
415
|
+
looksLikeOpenAIKey,
|
|
416
|
+
isClaudeAuthErrorText,
|
|
417
|
+
isClaudeUsageLimitText,
|
|
418
|
+
isCodexAuthErrorText,
|
|
419
|
+
summarizeAuthOutput,
|
|
420
|
+
summarizeClaudeAuthStatus,
|
|
421
|
+
claudeAuthRecoveryMessage,
|
|
422
|
+
claudeUsageLimitMessage,
|
|
423
|
+
runClaudeAuthStatusDiagnostic,
|
|
424
|
+
sendClaudeAuthStatusSummary,
|
|
425
|
+
runClaudeAuthCommand,
|
|
426
|
+
clearPendingClaudeAuth,
|
|
427
|
+
clearPendingCodexAuth,
|
|
428
|
+
runCommand,
|
|
429
|
+
runCodexLoginStatus,
|
|
430
|
+
sendCodexAuthStatusSummary,
|
|
431
|
+
saveCodexApiKeyWithCli,
|
|
432
|
+
runCodexDeviceLogin,
|
|
433
|
+
};
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
// Interactive wizard for /channel add kazee.
|
|
2
|
+
//
|
|
3
|
+
// State machine keyed by canonicalUserId. The router checks isAwaiting()
|
|
4
|
+
// before dispatching slash commands or media handlers, so once a user
|
|
5
|
+
// enters the wizard their next plain-text message becomes the wizard's
|
|
6
|
+
// input. A Cancel button (payload "chw:cancel") bails out at any step.
|
|
7
|
+
//
|
|
8
|
+
// validateKazee() is exported separately so setup.js can reuse the same
|
|
9
|
+
// /me probe without going through the chat-driven flow.
|
|
10
|
+
|
|
11
|
+
const { saveEnvKey, config } = require("./config");
|
|
12
|
+
const { send, deleteMessage } = require("./io");
|
|
13
|
+
const { publicCommands } = require("./commands");
|
|
14
|
+
const registry = require("./adapter-registry");
|
|
15
|
+
const { validateKazee } = require("./kazee-probe");
|
|
16
|
+
|
|
17
|
+
const sessions = new Map(); // canonicalUserId -> session
|
|
18
|
+
|
|
19
|
+
function isAwaiting(canonicalUserId) {
|
|
20
|
+
const s = sessions.get(canonicalUserId);
|
|
21
|
+
return !!s && s.step !== "idle";
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function getSession(canonicalUserId) {
|
|
25
|
+
return sessions.get(canonicalUserId) || null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function endSession(canonicalUserId) {
|
|
29
|
+
sessions.delete(canonicalUserId);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const cancelKeyboard = { inline_keyboard: [[{ text: "Cancel", callback_data: "chw:cancel" }]] };
|
|
33
|
+
|
|
34
|
+
async function start(canonicalUserId, kind) {
|
|
35
|
+
if (kind !== "kazee") {
|
|
36
|
+
await send(`Channel type "${kind}" not supported yet.`);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
sessions.set(canonicalUserId, { kind: "kazee", step: "awaiting-url", data: {} });
|
|
40
|
+
await send(
|
|
41
|
+
"Add a Kazee channel.\n\nStep 1 of 3: Kazee bot API URL (e.g. https://chat.example.com/api).",
|
|
42
|
+
{ keyboard: cancelKeyboard },
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function cancel(canonicalUserId, { silent } = {}) {
|
|
47
|
+
if (!sessions.has(canonicalUserId)) return;
|
|
48
|
+
sessions.delete(canonicalUserId);
|
|
49
|
+
if (!silent) await send("Channel setup cancelled.");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function handleText(envelope) {
|
|
53
|
+
const session = sessions.get(envelope.canonicalUserId);
|
|
54
|
+
if (!session) return;
|
|
55
|
+
const text = (envelope.text || "").trim();
|
|
56
|
+
|
|
57
|
+
if (session.step === "awaiting-url") {
|
|
58
|
+
if (!/^https?:\/\//i.test(text)) {
|
|
59
|
+
await send("That doesn't look like a URL. Try again, or hit Cancel.", { keyboard: cancelKeyboard });
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
session.data.url = text.replace(/\/$/, "");
|
|
63
|
+
session.step = "awaiting-token";
|
|
64
|
+
await send(
|
|
65
|
+
"Step 2 of 3: Kazee bot token. I'll delete your message after reading.",
|
|
66
|
+
{ keyboard: cancelKeyboard },
|
|
67
|
+
);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (session.step === "awaiting-token") {
|
|
72
|
+
await deleteMessage(envelope.messageId);
|
|
73
|
+
if (!text) {
|
|
74
|
+
await send("Empty token. Try again, or hit Cancel.", { keyboard: cancelKeyboard });
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
session.data.token = text;
|
|
78
|
+
session.step = "validating";
|
|
79
|
+
await send("Validating against the Kazee API…");
|
|
80
|
+
const check = await validateKazee({ url: session.data.url, token: session.data.token });
|
|
81
|
+
if (!check.ok) {
|
|
82
|
+
sessions.delete(envelope.canonicalUserId);
|
|
83
|
+
await send(`Validation failed: ${check.error}\n\nRun /channel add kazee to try again.`);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
session.data.botUserId = check.botUserId || "";
|
|
87
|
+
session.step = "awaiting-owner";
|
|
88
|
+
await send(
|
|
89
|
+
`Step 3 of 3: Kazee user id that should be treated as the owner (or send "skip" to leave blank).`,
|
|
90
|
+
{ keyboard: cancelKeyboard },
|
|
91
|
+
);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (session.step === "awaiting-owner") {
|
|
96
|
+
const ownerUserId = text.toLowerCase() === "skip" ? "" : text;
|
|
97
|
+
session.data.ownerUserId = ownerUserId;
|
|
98
|
+
session.step = "applying";
|
|
99
|
+
await applySession(envelope.canonicalUserId, session);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function handleAction(envelope) {
|
|
105
|
+
const payload = envelope.action?.payload || envelope.action?.buttonId || "";
|
|
106
|
+
if (payload !== "chw:cancel") return false;
|
|
107
|
+
await cancel(envelope.canonicalUserId);
|
|
108
|
+
return true;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function applySession(canonicalUserId, session) {
|
|
112
|
+
const { url, token, ownerUserId } = session.data;
|
|
113
|
+
|
|
114
|
+
// Capture rollback state so we can undo .env changes if the adapter
|
|
115
|
+
// refuses to start.
|
|
116
|
+
const previous = {
|
|
117
|
+
KAZEE_URL: config.KAZEE_URL || "",
|
|
118
|
+
KAZEE_BOT_TOKEN: config.KAZEE_BOT_TOKEN || "",
|
|
119
|
+
KAZEE_OWNER_USER_ID: config.KAZEE_OWNER_USER_ID || "",
|
|
120
|
+
CHANNELS: config.CHANNELS || "telegram",
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
saveEnvKey("KAZEE_URL", url);
|
|
124
|
+
saveEnvKey("KAZEE_BOT_TOKEN", token);
|
|
125
|
+
saveEnvKey("KAZEE_OWNER_USER_ID", ownerUserId);
|
|
126
|
+
|
|
127
|
+
const channels = previous.CHANNELS.split(",").map((s) => s.trim()).filter(Boolean);
|
|
128
|
+
if (!channels.some((c) => c === "kazee" || c.startsWith("kazee:"))) {
|
|
129
|
+
channels.push("kazee");
|
|
130
|
+
}
|
|
131
|
+
const newChannels = channels.join(",");
|
|
132
|
+
saveEnvKey("CHANNELS", newChannels);
|
|
133
|
+
|
|
134
|
+
// Refresh in-process view so subsequent loadChannels() sees the new
|
|
135
|
+
// values without a restart.
|
|
136
|
+
config.KAZEE_URL = url;
|
|
137
|
+
config.KAZEE_BOT_TOKEN = token;
|
|
138
|
+
config.KAZEE_OWNER_USER_ID = ownerUserId;
|
|
139
|
+
config.CHANNELS = newChannels;
|
|
140
|
+
|
|
141
|
+
const result = await registry.addAdapter(
|
|
142
|
+
{ id: "kazee", type: "kazee", opts: { url, token, ownerUserId } },
|
|
143
|
+
{ publicCommands: publicCommands() },
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
if (!result.ok) {
|
|
147
|
+
// Roll the .env back so the next bot start doesn't crash-loop.
|
|
148
|
+
saveEnvKey("KAZEE_URL", previous.KAZEE_URL);
|
|
149
|
+
saveEnvKey("KAZEE_BOT_TOKEN", previous.KAZEE_BOT_TOKEN);
|
|
150
|
+
saveEnvKey("KAZEE_OWNER_USER_ID", previous.KAZEE_OWNER_USER_ID);
|
|
151
|
+
saveEnvKey("CHANNELS", previous.CHANNELS);
|
|
152
|
+
config.KAZEE_URL = previous.KAZEE_URL;
|
|
153
|
+
config.KAZEE_BOT_TOKEN = previous.KAZEE_BOT_TOKEN;
|
|
154
|
+
config.KAZEE_OWNER_USER_ID = previous.KAZEE_OWNER_USER_ID;
|
|
155
|
+
config.CHANNELS = previous.CHANNELS;
|
|
156
|
+
sessions.delete(canonicalUserId);
|
|
157
|
+
await send(`Could not start the Kazee adapter: ${result.error}\n\nReverted .env. Run /channel add kazee to try again.`);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
sessions.delete(canonicalUserId);
|
|
162
|
+
await send("Kazee channel added and live.");
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
module.exports = {
|
|
166
|
+
isAwaiting,
|
|
167
|
+
getSession,
|
|
168
|
+
endSession,
|
|
169
|
+
start,
|
|
170
|
+
cancel,
|
|
171
|
+
handleText,
|
|
172
|
+
handleAction,
|
|
173
|
+
validateKazee,
|
|
174
|
+
};
|
package/core/commands.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// Slash-command registry. Replaces the spray of bot.onText() handlers.
|
|
2
|
+
// Each entry declares metadata (so adapters can register the slash menu)
|
|
3
|
+
// + a handler. Both adapters dispatch through one parser → one handler
|
|
4
|
+
// per command.
|
|
5
|
+
|
|
6
|
+
const _commands = [];
|
|
7
|
+
|
|
8
|
+
function register(spec) {
|
|
9
|
+
if (!spec || !spec.name) throw new Error("command needs a name");
|
|
10
|
+
if (!spec.handler && !spec.match) throw new Error(`command ${spec.name} needs a handler or match()`);
|
|
11
|
+
const entry = {
|
|
12
|
+
name: String(spec.name).replace(/^\//, ""),
|
|
13
|
+
aliases: (spec.aliases || []).map((a) => String(a).replace(/^\//, "")),
|
|
14
|
+
description: spec.description || "",
|
|
15
|
+
args: spec.args || "",
|
|
16
|
+
hidden: !!spec.hidden,
|
|
17
|
+
ownerOnly: !!spec.ownerOnly,
|
|
18
|
+
authRequired: spec.authRequired !== false,
|
|
19
|
+
pattern: spec.pattern || null, // optional regex if you need richer parsing
|
|
20
|
+
match: spec.match || null, // (text, envelope) → falsy or { args, regexMatch }
|
|
21
|
+
handler: spec.handler,
|
|
22
|
+
};
|
|
23
|
+
_commands.push(entry);
|
|
24
|
+
return entry;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function list() {
|
|
28
|
+
return _commands.slice();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Public-facing command list for adapter.registerCommands(). Hides
|
|
32
|
+
// internal-only entries and the regex-matched variants that share a
|
|
33
|
+
// canonical name.
|
|
34
|
+
function publicCommands() {
|
|
35
|
+
const seen = new Set();
|
|
36
|
+
const out = [];
|
|
37
|
+
for (const c of _commands) {
|
|
38
|
+
if (c.hidden) continue;
|
|
39
|
+
if (seen.has(c.name)) continue;
|
|
40
|
+
seen.add(c.name);
|
|
41
|
+
out.push({ name: c.name, description: c.description, args: c.args });
|
|
42
|
+
}
|
|
43
|
+
return out;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Dispatch incoming text. Returns { matched: true } if a command ran.
|
|
47
|
+
async function dispatch(text, envelope) {
|
|
48
|
+
const trimmed = String(text || "").trim();
|
|
49
|
+
if (!trimmed.startsWith("/")) return { matched: false };
|
|
50
|
+
const space = trimmed.indexOf(" ");
|
|
51
|
+
const cmdRaw = (space >= 0 ? trimmed.slice(1, space) : trimmed.slice(1)).toLowerCase();
|
|
52
|
+
const tail = space >= 0 ? trimmed.slice(space + 1).trim() : "";
|
|
53
|
+
// Strip @botname suffix Telegram appends in groups (kept for safety).
|
|
54
|
+
const cmd = cmdRaw.split("@")[0];
|
|
55
|
+
|
|
56
|
+
for (const c of _commands) {
|
|
57
|
+
if (c.name !== cmd && !c.aliases.includes(cmd)) continue;
|
|
58
|
+
let matchInfo = null;
|
|
59
|
+
if (c.pattern) {
|
|
60
|
+
const m = trimmed.match(c.pattern);
|
|
61
|
+
if (!m) continue;
|
|
62
|
+
matchInfo = m;
|
|
63
|
+
} else if (c.match) {
|
|
64
|
+
matchInfo = c.match(trimmed, envelope);
|
|
65
|
+
if (!matchInfo) continue;
|
|
66
|
+
}
|
|
67
|
+
await c.handler(envelope, { tail, args: tail ? tail.split(/\s+/) : [], match: matchInfo });
|
|
68
|
+
return { matched: true, command: c };
|
|
69
|
+
}
|
|
70
|
+
return { matched: false };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
module.exports = { register, list, publicCommands, dispatch };
|