@inetafrica/open-claudia 1.13.3 → 1.14.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/CHANGELOG.md +6 -0
- package/README.md +15 -1
- package/bot-agent.js +355 -6
- package/bot.js +349 -11
- package/package.json +1 -1
- package/setup.js +112 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## v1.14.0
|
|
4
|
+
- Claude Code auth management from Telegram: `/auth_status`, `/login`, `/setup_token`, `/use_oauth_token`, and `/clear_oauth_token`
|
|
5
|
+
- Claude subprocesses now receive `CLAUDE_CODE_OAUTH_TOKEN` from config/env/vault when available, avoiding launchd/macOS Keychain auth failures
|
|
6
|
+
- Sensitive Claude tokens are redacted from Telegram output and logs
|
|
7
|
+
- Package lock metadata corrected to match `@inetafrica/open-claudia`
|
|
8
|
+
|
|
3
9
|
## v1.12.0
|
|
4
10
|
- Cursor Agent tool progress: Shell, Read, Edit, Write, Grep, Glob calls now show in real-time Telegram updates
|
|
5
11
|
- Plan output surfaced to Telegram: when Cursor creates a plan (`--mode plan`), the full plan markdown and task list are sent to the user
|
package/README.md
CHANGED
|
@@ -55,7 +55,7 @@ agent login # Opens browser to authenticate
|
|
|
55
55
|
agent status # Verify: should show your email and plan
|
|
56
56
|
```
|
|
57
57
|
|
|
58
|
-
> **Important**:
|
|
58
|
+
> **Important**: Claude Code can use macOS Keychain when you log in interactively, but a launchd/background bot may not be able to read that Keychain session. Open Claudia v1.14.0 adds Telegram auth helpers and supports `CLAUDE_CODE_OAUTH_TOKEN` for non-interactive Claude runs. Prefer `/setup_token` then `/use_oauth_token` if Telegram shows Claude auth/keychain errors.
|
|
59
59
|
|
|
60
60
|
### 2. Install Open Claudia
|
|
61
61
|
|
|
@@ -141,6 +141,20 @@ When you select a project, the last conversation is automatically resumed. Tap "
|
|
|
141
141
|
| `/vault` | Manage encrypted credentials (password required) |
|
|
142
142
|
| `/soul` | View/edit assistant identity and personality |
|
|
143
143
|
|
|
144
|
+
### Claude Code Auth
|
|
145
|
+
|
|
146
|
+
| Command | Description |
|
|
147
|
+
|---------|-------------|
|
|
148
|
+
| `/auth_status` | Runs `claude auth status` and reports redacted status plus whether the bot has an OAuth token configured |
|
|
149
|
+
| `/login` | Starts `claude auth login --claudeai --email sumeet@inet.africa`, sends the login URL/code, and accepts paste-back codes |
|
|
150
|
+
| `/setup_token` | Starts `claude setup-token`; if Claude prints an OAuth token, Open Claudia stores it without echoing it |
|
|
151
|
+
| `/use_oauth_token <token>` | Stores `CLAUDE_CODE_OAUTH_TOKEN` in config for launchd/non-interactive Claude runs; the Telegram message is deleted when possible |
|
|
152
|
+
| `/use_oauth_token` | Secure pending mode: send the token as the next message and it will be deleted/stored without echo |
|
|
153
|
+
| `/clear_oauth_token` | Removes the stored OAuth token from config/process, and from vault if it is unlocked |
|
|
154
|
+
|
|
155
|
+
Tokens are redacted from Telegram output and logs. If the encrypted vault is unlocked, `/use_oauth_token` also mirrors the token into the vault, but `.env` storage is what lets launchd pass `CLAUDE_CODE_OAUTH_TOKEN` to Claude without relying on macOS Keychain.
|
|
156
|
+
|
|
157
|
+
|
|
144
158
|
### System
|
|
145
159
|
|
|
146
160
|
| Command | Description |
|
package/bot-agent.js
CHANGED
|
@@ -190,6 +190,9 @@ bot.setMyCommands([
|
|
|
190
190
|
{ command: "cursor", description: "Switch to Cursor Agent backend" },
|
|
191
191
|
{ command: "claude", description: "Switch to Claude Code backend" },
|
|
192
192
|
{ command: "backend", description: "Show/switch active backend" },
|
|
193
|
+
{ command: "auth_status", description: "Check Claude Code auth" },
|
|
194
|
+
{ command: "login", description: "Start Claude Code login" },
|
|
195
|
+
{ command: "setup_token", description: "Create Claude OAuth token" },
|
|
193
196
|
{ command: "stop", description: "Cancel running task" },
|
|
194
197
|
{ command: "end", description: "End current session" },
|
|
195
198
|
{ command: "version", description: "Show current version" },
|
|
@@ -298,7 +301,10 @@ let messageQueue = []; // Fallback queue (only used if chat process a
|
|
|
298
301
|
let activeCrons = new Map();
|
|
299
302
|
let pendingVaultUnlock = false;
|
|
300
303
|
let pendingVaultAction = null;
|
|
304
|
+
let pendingClaudeAuthProcess = null;
|
|
305
|
+
let pendingClaudeAuthLabel = null;
|
|
301
306
|
let isFirstMessage = !lastSessionId;
|
|
307
|
+
let lastInputWasVoice = false;
|
|
302
308
|
|
|
303
309
|
let settings = savedState.settings || {
|
|
304
310
|
model: null, effort: null, budget: null, permissionMode: null, worktree: false, backend: "claude",
|
|
@@ -661,11 +667,245 @@ function transcribeAudio(oggPath) {
|
|
|
661
667
|
.join(" ").trim();
|
|
662
668
|
}
|
|
663
669
|
|
|
670
|
+
// ── Text-to-Speech ────────────────────────────────────────────────
|
|
671
|
+
|
|
672
|
+
const TTS_CMD = process.platform === "darwin" ? "say" : null;
|
|
673
|
+
|
|
674
|
+
function textToVoice(text) {
|
|
675
|
+
if (!TTS_CMD || !FFMPEG) return null;
|
|
676
|
+
try {
|
|
677
|
+
const clean = text.replace(/[*_`#>\[\]()]/g, "").replace(/\n{2,}/g, ". ").replace(/\n/g, " ").trim();
|
|
678
|
+
if (!clean) return null;
|
|
679
|
+
const aiffPath = path.join(TEMP_DIR, `tts-${Date.now()}.aiff`);
|
|
680
|
+
const oggPath = aiffPath.replace(".aiff", ".ogg");
|
|
681
|
+
execSync(`${TTS_CMD} ${JSON.stringify(clean)} -o "${aiffPath}"`, { timeout: 30000 });
|
|
682
|
+
execSync(`"${FFMPEG}" -i "${aiffPath}" -c:a libopus -y "${oggPath}" 2>/dev/null`, { timeout: 30000 });
|
|
683
|
+
try { fs.unlinkSync(aiffPath); } catch (e) {}
|
|
684
|
+
return oggPath;
|
|
685
|
+
} catch (e) {
|
|
686
|
+
console.error("TTS error:", e.message);
|
|
687
|
+
return null;
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
async function sendVoice(oggPath) {
|
|
692
|
+
try {
|
|
693
|
+
await bot.sendVoice(CHAT_ID, oggPath);
|
|
694
|
+
try { fs.unlinkSync(oggPath); } catch (e) {}
|
|
695
|
+
return true;
|
|
696
|
+
} catch (e) {
|
|
697
|
+
console.error("Send voice error:", e.message);
|
|
698
|
+
try { fs.unlinkSync(oggPath); } catch (e2) {}
|
|
699
|
+
return false;
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
|
|
664
703
|
// Delete a message (used for vault password cleanup)
|
|
665
704
|
async function deleteMessage(msgId) {
|
|
666
705
|
try { await bot.deleteMessage(CHAT_ID, msgId); } catch (e) { /* ignore */ }
|
|
667
706
|
}
|
|
668
707
|
|
|
708
|
+
|
|
709
|
+
// ── Claude Auth Helpers ─────────────────────────────────────────────
|
|
710
|
+
|
|
711
|
+
const CLAUDE_OAUTH_TOKEN_KEY = "CLAUDE_CODE_OAUTH_TOKEN";
|
|
712
|
+
const CLAUDE_OAUTH_VAULT_KEY = "claude_oauth_token";
|
|
713
|
+
|
|
714
|
+
function redactSensitive(value) {
|
|
715
|
+
return String(value || "")
|
|
716
|
+
.replace(/sk-ant-[A-Za-z0-9._-]+/g, "[REDACTED_TOKEN]")
|
|
717
|
+
.replace(/(Bearer\s+)[A-Za-z0-9._=-]+/gi, "$1[REDACTED_TOKEN]")
|
|
718
|
+
.replace(/(CLAUDE_CODE_OAUTH_TOKEN\s*=\s*)\S+/gi, "$1[REDACTED_TOKEN]")
|
|
719
|
+
.replace(/([?&](?:token|access_token|refresh_token)=)[^\s&]+/gi, "$1[REDACTED]");
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
function looksLikeClaudeToken(value) {
|
|
723
|
+
const text = String(value || "").trim();
|
|
724
|
+
return /^sk-ant-[A-Za-z0-9._-]+$/.test(text) || text.length >= 80 && /^[A-Za-z0-9._=-]+$/.test(text);
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
function getClaudeOAuthToken() {
|
|
728
|
+
if (config[CLAUDE_OAUTH_TOKEN_KEY]) return { value: config[CLAUDE_OAUTH_TOKEN_KEY], source: ".env" };
|
|
729
|
+
if (process.env.CLAUDE_CODE_OAUTH_TOKEN) return { value: process.env.CLAUDE_CODE_OAUTH_TOKEN, source: "process env" };
|
|
730
|
+
if (vault.isUnlocked()) {
|
|
731
|
+
const value = vault.get(CLAUDE_OAUTH_VAULT_KEY) || vault.get(CLAUDE_OAUTH_TOKEN_KEY);
|
|
732
|
+
if (value) return { value, source: "vault" };
|
|
733
|
+
}
|
|
734
|
+
return { value: null, source: null };
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
function claudeSubprocessEnv() {
|
|
738
|
+
const env = { ...process.env, PATH: FULL_PATH, HOME: process.env.HOME };
|
|
739
|
+
const token = getClaudeOAuthToken().value;
|
|
740
|
+
if (token) env.CLAUDE_CODE_OAUTH_TOKEN = token;
|
|
741
|
+
return env;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
function saveClaudeOAuthToken(token) {
|
|
745
|
+
const clean = String(token || "").trim();
|
|
746
|
+
if (!clean) return false;
|
|
747
|
+
saveEnvKey(CLAUDE_OAUTH_TOKEN_KEY, clean);
|
|
748
|
+
config[CLAUDE_OAUTH_TOKEN_KEY] = clean;
|
|
749
|
+
process.env.CLAUDE_CODE_OAUTH_TOKEN = clean;
|
|
750
|
+
if (vault.isUnlocked()) vault.set(CLAUDE_OAUTH_VAULT_KEY, clean);
|
|
751
|
+
return true;
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
function clearClaudeOAuthToken() {
|
|
755
|
+
saveEnvKey(CLAUDE_OAUTH_TOKEN_KEY, "");
|
|
756
|
+
config[CLAUDE_OAUTH_TOKEN_KEY] = "";
|
|
757
|
+
delete process.env.CLAUDE_CODE_OAUTH_TOKEN;
|
|
758
|
+
if (vault.isUnlocked()) {
|
|
759
|
+
vault.remove(CLAUDE_OAUTH_VAULT_KEY);
|
|
760
|
+
vault.remove(CLAUDE_OAUTH_TOKEN_KEY);
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
function extractClaudeToken(text) {
|
|
765
|
+
const match = String(text || "").match(/sk-ant-[A-Za-z0-9._-]+/);
|
|
766
|
+
return match ? match[0] : null;
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
function extractUrls(text) {
|
|
770
|
+
return [...String(text || "").matchAll(/https?:\/\/[^\s)]+/g)].map((m) => m[0]);
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
|
|
774
|
+
function isClaudeAuthErrorText(text) {
|
|
775
|
+
const lower = String(text || "").toLowerCase();
|
|
776
|
+
return lower.includes("unauthorized") ||
|
|
777
|
+
lower.includes("not logged in") ||
|
|
778
|
+
lower.includes("login required") ||
|
|
779
|
+
lower.includes("reauthenticate") ||
|
|
780
|
+
lower.includes("re-authenticate") ||
|
|
781
|
+
lower.includes("authentication failed") ||
|
|
782
|
+
lower.includes("auth failed") ||
|
|
783
|
+
lower.includes("invalid api key") ||
|
|
784
|
+
lower.includes("api key") && lower.includes("invalid") ||
|
|
785
|
+
lower.includes("keychain") && (lower.includes("unlock") || lower.includes("could not") || lower.includes("failed")) ||
|
|
786
|
+
lower.includes("security unlock-keychain");
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
function claudeAuthRecoveryMessage(reason = "Claude Code authentication failed") {
|
|
790
|
+
const tokenInfo = getClaudeOAuthToken();
|
|
791
|
+
const tokenLine = tokenInfo.value
|
|
792
|
+
? `Stored OAuth token: yes (${tokenInfo.source})`
|
|
793
|
+
: "Stored OAuth token: no";
|
|
794
|
+
return [
|
|
795
|
+
`Claude auth needs attention: ${redactSensitive(reason)}`,
|
|
796
|
+
"",
|
|
797
|
+
tokenLine,
|
|
798
|
+
"",
|
|
799
|
+
"Try one of these from Telegram:",
|
|
800
|
+
"1. /auth_status — check what Claude sees",
|
|
801
|
+
"2. /setup_token — create a long-lived Claude Code token, then store it",
|
|
802
|
+
"3. /use_oauth_token — paste the token securely if you already generated one",
|
|
803
|
+
"4. /login — interactive Claude.ai browser login fallback",
|
|
804
|
+
"",
|
|
805
|
+
"If this is a macOS Keychain problem after SSH/reboot, run on the Mac:",
|
|
806
|
+
"security unlock-keychain",
|
|
807
|
+
"",
|
|
808
|
+
"Recommended for this bot: /setup_token + /use_oauth_token so launchd does not depend on Keychain."
|
|
809
|
+
].join("\n");
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
function preflightClaudeAuthMessage() {
|
|
813
|
+
if (settings.backend === "cursor") return null;
|
|
814
|
+
if (getClaudeOAuthToken().value) return null;
|
|
815
|
+
try {
|
|
816
|
+
const output = execSync(`"${CLAUDE_PATH}" auth status`, {
|
|
817
|
+
cwd: process.env.HOME || require("os").homedir(),
|
|
818
|
+
env: claudeSubprocessEnv(),
|
|
819
|
+
encoding: "utf8",
|
|
820
|
+
timeout: 10000,
|
|
821
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
822
|
+
});
|
|
823
|
+
if (isClaudeAuthErrorText(output)) return claudeAuthRecoveryMessage(output.trim().slice(-500));
|
|
824
|
+
return null;
|
|
825
|
+
} catch (e) {
|
|
826
|
+
const output = `${e.stdout || ""}\n${e.stderr || ""}\n${e.message || ""}`;
|
|
827
|
+
if (isClaudeAuthErrorText(output)) return claudeAuthRecoveryMessage(output.trim().slice(-500));
|
|
828
|
+
return null;
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
|
|
833
|
+
function summarizeClaudeAuthStatus(output, exitCode, tokenInfo) {
|
|
834
|
+
const text = String(output || "");
|
|
835
|
+
const lower = text.toLowerCase();
|
|
836
|
+
const loggedOut = /not (logged in|authenticated)|unauthenticated|no auth|login required/.test(lower);
|
|
837
|
+
const loggedIn = !loggedOut && (exitCode === 0 || /logged in|authenticated|claude\.ai|anthropic/.test(lower));
|
|
838
|
+
const email = (text.match(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/i) || [null])[0];
|
|
839
|
+
const provider = /claude\.ai|claudeai/.test(lower) ? "Claude.ai" : (/anthropic/.test(lower) ? "Anthropic" : "unknown");
|
|
840
|
+
let method = tokenInfo.value ? `OAuth token (${tokenInfo.source})` : "unknown";
|
|
841
|
+
if (/api key|apikey/.test(lower)) method = "API key";
|
|
842
|
+
else if (/oauth|claude\.ai|claudeai/.test(lower)) method = tokenInfo.value ? `OAuth token (${tokenInfo.source})` : "OAuth/Claude.ai";
|
|
843
|
+
return [
|
|
844
|
+
`Logged in: ${loggedIn ? "yes" : (loggedOut ? "no" : "unknown")}`,
|
|
845
|
+
`Auth method: ${method}`,
|
|
846
|
+
`Email: ${email || "unknown"}`,
|
|
847
|
+
`Provider: ${provider}`,
|
|
848
|
+
];
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
async function runClaudeAuthCommand(args, label, opts = {}) {
|
|
852
|
+
if (pendingClaudeAuthProcess) {
|
|
853
|
+
await send(`Another Claude auth flow is already running (${pendingClaudeAuthLabel}). Send the requested code/token or wait for it to finish.`);
|
|
854
|
+
return;
|
|
855
|
+
}
|
|
856
|
+
await send(`${label} started...`);
|
|
857
|
+
const proc = spawn(CLAUDE_PATH, args, {
|
|
858
|
+
cwd: process.env.HOME || require("os").homedir(),
|
|
859
|
+
env: claudeSubprocessEnv(),
|
|
860
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
861
|
+
});
|
|
862
|
+
pendingClaudeAuthProcess = proc;
|
|
863
|
+
pendingClaudeAuthLabel = label;
|
|
864
|
+
let output = "";
|
|
865
|
+
let sentUrls = new Set();
|
|
866
|
+
let tokenStored = false;
|
|
867
|
+
let lastSnippetAt = 0;
|
|
868
|
+
|
|
869
|
+
const handleChunk = async (chunk) => {
|
|
870
|
+
output += chunk;
|
|
871
|
+
const token = opts.captureToken ? extractClaudeToken(chunk) || extractClaudeToken(output) : null;
|
|
872
|
+
if (token && !tokenStored) {
|
|
873
|
+
tokenStored = saveClaudeOAuthToken(token);
|
|
874
|
+
await send(tokenStored ? "Claude OAuth token captured and stored. I did not print it." : "Claude OAuth token appeared, but I could not store it.");
|
|
875
|
+
}
|
|
876
|
+
for (const url of extractUrls(chunk)) {
|
|
877
|
+
if (!sentUrls.has(url)) {
|
|
878
|
+
sentUrls.add(url);
|
|
879
|
+
await send(`${label} URL:\n${redactSensitive(url)}\n\nOpen it, complete the flow, then paste any code Claude asks for here.`);
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
const redacted = redactSensitive(chunk).trim();
|
|
883
|
+
const now = Date.now();
|
|
884
|
+
if (redacted && now - lastSnippetAt > 3000 && !looksLikeClaudeToken(redacted)) {
|
|
885
|
+
lastSnippetAt = now;
|
|
886
|
+
await send(redacted.length > 1200 ? redacted.slice(-1200) : redacted);
|
|
887
|
+
}
|
|
888
|
+
};
|
|
889
|
+
|
|
890
|
+
proc.stdout.on("data", (d) => handleChunk(d.toString()).catch((e) => console.error("Auth output error:", e.message)));
|
|
891
|
+
proc.stderr.on("data", (d) => handleChunk(d.toString()).catch((e) => console.error("Auth stderr error:", e.message)));
|
|
892
|
+
proc.on("close", async (code) => {
|
|
893
|
+
pendingClaudeAuthProcess = null;
|
|
894
|
+
pendingClaudeAuthLabel = null;
|
|
895
|
+
const token = opts.captureToken ? extractClaudeToken(output) : null;
|
|
896
|
+
if (token && !tokenStored) tokenStored = saveClaudeOAuthToken(token);
|
|
897
|
+
const final = redactSensitive(output.trim());
|
|
898
|
+
if (tokenStored) await send(`${label} finished. OAuth token stored for launchd/non-interactive Claude runs.`);
|
|
899
|
+
else if (final) await send(`${label} finished (exit ${code}).\n\n${final.slice(-2500)}`);
|
|
900
|
+
else await send(`${label} finished (exit ${code}).`);
|
|
901
|
+
});
|
|
902
|
+
proc.on("error", async (err) => {
|
|
903
|
+
pendingClaudeAuthProcess = null;
|
|
904
|
+
pendingClaudeAuthLabel = null;
|
|
905
|
+
await send(`${label} failed: ${redactSensitive(err.message)}`);
|
|
906
|
+
});
|
|
907
|
+
}
|
|
908
|
+
|
|
669
909
|
// ── Claude Runner ───────────────────────────────────────────────────
|
|
670
910
|
|
|
671
911
|
function parseStreamEvents(data) {
|
|
@@ -734,7 +974,7 @@ async function runClaudeChat(prompt, cwd, replyToMsgId) {
|
|
|
734
974
|
|
|
735
975
|
const proc = spawn(CLAUDE_PATH, args, {
|
|
736
976
|
cwd,
|
|
737
|
-
env:
|
|
977
|
+
env: claudeSubprocessEnv(),
|
|
738
978
|
stdio: ["ignore", "pipe", "pipe"],
|
|
739
979
|
});
|
|
740
980
|
|
|
@@ -779,6 +1019,12 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
|
|
|
779
1019
|
return;
|
|
780
1020
|
}
|
|
781
1021
|
|
|
1022
|
+
const authPreflight = preflightClaudeAuthMessage();
|
|
1023
|
+
if (authPreflight) {
|
|
1024
|
+
await send(authPreflight, { replyTo: replyToMsgId });
|
|
1025
|
+
return;
|
|
1026
|
+
}
|
|
1027
|
+
|
|
782
1028
|
bot.sendChatAction(CHAT_ID, "typing");
|
|
783
1029
|
statusMessageId = null;
|
|
784
1030
|
streamBuffer = "";
|
|
@@ -791,7 +1037,7 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
|
|
|
791
1037
|
const binaryPath = getActiveBinary();
|
|
792
1038
|
const proc = spawn(binaryPath, args, {
|
|
793
1039
|
cwd,
|
|
794
|
-
env:
|
|
1040
|
+
env: claudeSubprocessEnv(),
|
|
795
1041
|
stdio: ["ignore", "pipe", "pipe"],
|
|
796
1042
|
detached: process.platform !== "win32", // Create process group so /stop kills children too
|
|
797
1043
|
});
|
|
@@ -907,13 +1153,26 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
|
|
|
907
1153
|
}
|
|
908
1154
|
});
|
|
909
1155
|
|
|
910
|
-
|
|
1156
|
+
let stderrBuffer = "";
|
|
1157
|
+
proc.stderr.on("data", (d) => {
|
|
1158
|
+
const chunk = d.toString();
|
|
1159
|
+
stderrBuffer += chunk;
|
|
1160
|
+
console.error("STDERR:", redactSensitive(chunk));
|
|
1161
|
+
});
|
|
911
1162
|
|
|
912
1163
|
proc.on("close", async (code) => {
|
|
913
1164
|
runningProcess = null; runningProcessPrompt = null;
|
|
914
1165
|
clearTimeout(streamInterval); streamInterval = null;
|
|
1166
|
+
if (settings.backend !== "cursor" && isClaudeAuthErrorText(stderrBuffer)) {
|
|
1167
|
+
await send(claudeAuthRecoveryMessage(stderrBuffer.trim().slice(-800)), { replyTo: replyToMsgId });
|
|
1168
|
+
return;
|
|
1169
|
+
}
|
|
1170
|
+
if (settings.backend === "cursor" && isClaudeAuthErrorText(stderrBuffer)) {
|
|
1171
|
+
await send("Cursor authentication error. Run `agent login` on this machine, then retry.", { replyTo: replyToMsgId });
|
|
1172
|
+
return;
|
|
1173
|
+
}
|
|
915
1174
|
try {
|
|
916
|
-
const finalText = assistantText || "(no output)";
|
|
1175
|
+
const finalText = redactSensitive(assistantText || "(no output)");
|
|
917
1176
|
const chunks = splitMessage(finalText);
|
|
918
1177
|
const firstChunk = chunks[0];
|
|
919
1178
|
|
|
@@ -927,6 +1186,13 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
|
|
|
927
1186
|
}
|
|
928
1187
|
}
|
|
929
1188
|
if (code !== 0 && code !== null) await send(`Exit code: ${code}`);
|
|
1189
|
+
|
|
1190
|
+
// Send voice reply if input was a voice note
|
|
1191
|
+
if (lastInputWasVoice && TTS_CMD) {
|
|
1192
|
+
lastInputWasVoice = false;
|
|
1193
|
+
const voicePath = textToVoice(finalText);
|
|
1194
|
+
if (voicePath) await sendVoice(voicePath);
|
|
1195
|
+
}
|
|
930
1196
|
} catch (e) {
|
|
931
1197
|
console.error("Final message delivery failed:", e.message);
|
|
932
1198
|
await send("Task completed but failed to deliver the response. Send /continue to see the result.");
|
|
@@ -958,13 +1224,13 @@ async function runClaudeSilent(prompt, cwd, label) {
|
|
|
958
1224
|
"--append-system-prompt", buildSystemPrompt(),
|
|
959
1225
|
"--dangerously-skip-permissions", prompt];
|
|
960
1226
|
const proc = spawn(CLAUDE_PATH, args, {
|
|
961
|
-
cwd, env:
|
|
1227
|
+
cwd, env: claudeSubprocessEnv(),
|
|
962
1228
|
stdio: ["ignore", "pipe", "pipe"],
|
|
963
1229
|
});
|
|
964
1230
|
let output = "";
|
|
965
1231
|
proc.stdout.on("data", (d) => { output += d.toString(); });
|
|
966
1232
|
proc.on("close", async () => {
|
|
967
|
-
const chunks = splitMessage(`Cron: ${label}\n\n${output.trim() || "(no output)"}`);
|
|
1233
|
+
const chunks = splitMessage(`Cron: ${label}\n\n${redactSensitive(output.trim() || "(no output)")}`);
|
|
968
1234
|
for (const c of chunks) await send(c);
|
|
969
1235
|
resolve();
|
|
970
1236
|
});
|
|
@@ -1064,6 +1330,7 @@ bot.onText(/\/help/, (msg) => {
|
|
|
1064
1330
|
"Session: /session /sessions /projects /continue /status /stop /end",
|
|
1065
1331
|
"Settings: /model /effort /budget /plan /compact /worktree /mode",
|
|
1066
1332
|
"Automation: /cron /vault /soul",
|
|
1333
|
+
"Claude auth: /auth_status /login /setup_token /use_oauth_token /clear_oauth_token",
|
|
1067
1334
|
"System: /restart /upgrade",
|
|
1068
1335
|
"",
|
|
1069
1336
|
"Send text, voice, photos, or files.",
|
|
@@ -1312,6 +1579,64 @@ bot.onText(/\/soul$/, async (msg) => {
|
|
|
1312
1579
|
await send(`Edit: ${SOUL_FILE}\nOr tell me what to change and I'll update it.`);
|
|
1313
1580
|
});
|
|
1314
1581
|
|
|
1582
|
+
// ── Claude Auth Commands ────────────────────────────────────────────
|
|
1583
|
+
|
|
1584
|
+
bot.onText(/\/(?:auth_status|auth status)$/, async (msg) => {
|
|
1585
|
+
if (!isAuthorized(msg)) return;
|
|
1586
|
+
const tokenInfo = getClaudeOAuthToken();
|
|
1587
|
+
const proc = spawn(CLAUDE_PATH, ["auth", "status"], {
|
|
1588
|
+
cwd: process.env.HOME || require("os").homedir(),
|
|
1589
|
+
env: claudeSubprocessEnv(),
|
|
1590
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
1591
|
+
});
|
|
1592
|
+
let output = "";
|
|
1593
|
+
proc.stdout.on("data", (d) => { output += d.toString(); });
|
|
1594
|
+
proc.stderr.on("data", (d) => { output += d.toString(); });
|
|
1595
|
+
proc.on("close", async (code) => {
|
|
1596
|
+
const clean = redactSensitive(output.trim()) || "(no output)";
|
|
1597
|
+
await send([
|
|
1598
|
+
`Claude auth status: exit ${code}`,
|
|
1599
|
+
...summarizeClaudeAuthStatus(output, code, tokenInfo),
|
|
1600
|
+
`Bot OAuth token: ${tokenInfo.value ? "configured via " + tokenInfo.source : "not configured"}`,
|
|
1601
|
+
`Vault: ${vault.isUnlocked() ? "unlocked" : "locked"}`,
|
|
1602
|
+
"",
|
|
1603
|
+
clean.slice(-2500),
|
|
1604
|
+
].join("\n"));
|
|
1605
|
+
});
|
|
1606
|
+
proc.on("error", async (err) => send(`Claude auth status failed: ${redactSensitive(err.message)}`));
|
|
1607
|
+
});
|
|
1608
|
+
|
|
1609
|
+
bot.onText(/\/login$/, async (msg) => {
|
|
1610
|
+
if (!isAuthorized(msg)) return;
|
|
1611
|
+
await runClaudeAuthCommand(["auth", "login", "--claudeai", "--email", "sumeet@inet.africa"], "Claude login");
|
|
1612
|
+
});
|
|
1613
|
+
|
|
1614
|
+
bot.onText(/\/setup_token$/, async (msg) => {
|
|
1615
|
+
if (!isAuthorized(msg)) return;
|
|
1616
|
+
await runClaudeAuthCommand(["setup-token"], "Claude setup-token", { captureToken: true });
|
|
1617
|
+
});
|
|
1618
|
+
|
|
1619
|
+
bot.onText(/\/use_oauth_token(?:\s+(.+))?$/, async (msg, match) => {
|
|
1620
|
+
if (!isAuthorized(msg)) return;
|
|
1621
|
+
const token = (match[1] || "").trim();
|
|
1622
|
+
await deleteMessage(msg.message_id);
|
|
1623
|
+
if (!token) {
|
|
1624
|
+
pendingClaudeAuthProcess = { stdin: { write: (value) => saveClaudeOAuthToken(value.trim()) } };
|
|
1625
|
+
pendingClaudeAuthLabel = "manual OAuth token save";
|
|
1626
|
+
await send("Send the Claude OAuth token in your next message. I'll delete it and store it without echoing it.");
|
|
1627
|
+
return;
|
|
1628
|
+
}
|
|
1629
|
+
if (!looksLikeClaudeToken(token)) return send("That doesn't look like a Claude OAuth token. Not saved.");
|
|
1630
|
+
saveClaudeOAuthToken(token);
|
|
1631
|
+
await send(`Claude OAuth token stored in .env${vault.isUnlocked() ? " and vault" : ""}. Restart the bot so launchd picks it up, or use /restart.`);
|
|
1632
|
+
});
|
|
1633
|
+
|
|
1634
|
+
bot.onText(/\/clear_oauth_token$/, async (msg) => {
|
|
1635
|
+
if (!isAuthorized(msg)) return;
|
|
1636
|
+
clearClaudeOAuthToken();
|
|
1637
|
+
await send("Claude OAuth token cleared from .env/process" + (vault.isUnlocked() ? " and vault." : ". Unlock vault and run again if you also stored it there."));
|
|
1638
|
+
});
|
|
1639
|
+
|
|
1315
1640
|
// ── /vault with password protection ─────────────────────────────────
|
|
1316
1641
|
|
|
1317
1642
|
bot.onText(/\/vault$/, async (msg) => {
|
|
@@ -1481,6 +1806,7 @@ bot.on("voice", async (msg) => {
|
|
|
1481
1806
|
try { fs.unlinkSync(oggPath); } catch (e) {}
|
|
1482
1807
|
if (!transcript) return send("Couldn't transcribe. Try typing it.");
|
|
1483
1808
|
await send(`Heard: "${transcript}"`, { replyTo: msg.message_id });
|
|
1809
|
+
lastInputWasVoice = true;
|
|
1484
1810
|
await runClaude(transcript, currentSession.dir, msg.message_id);
|
|
1485
1811
|
} catch (err) { await send(`Voice failed: ${err.message}`); }
|
|
1486
1812
|
});
|
|
@@ -1547,6 +1873,29 @@ bot.on("message", async (msg) => {
|
|
|
1547
1873
|
if (msg.voice || msg.audio || msg.photo || msg.document || msg.video || msg.sticker) return;
|
|
1548
1874
|
if (isDuplicate(msg.message_id)) return;
|
|
1549
1875
|
|
|
1876
|
+
// Handle pending Claude auth/token paste-back
|
|
1877
|
+
if (pendingClaudeAuthProcess) {
|
|
1878
|
+
const text = msg.text.trim();
|
|
1879
|
+
await deleteMessage(msg.message_id);
|
|
1880
|
+
if (pendingClaudeAuthLabel === "manual OAuth token save") {
|
|
1881
|
+
pendingClaudeAuthProcess = null;
|
|
1882
|
+
pendingClaudeAuthLabel = null;
|
|
1883
|
+
if (!looksLikeClaudeToken(text)) { await send("That doesn't look like a Claude OAuth token. Not saved."); return; }
|
|
1884
|
+
saveClaudeOAuthToken(text);
|
|
1885
|
+
await send(`Claude OAuth token stored in .env${vault.isUnlocked() ? " and vault" : ""}. Restart the bot so launchd picks it up, or use /restart.`);
|
|
1886
|
+
return;
|
|
1887
|
+
}
|
|
1888
|
+
try {
|
|
1889
|
+
pendingClaudeAuthProcess.stdin.write(text + "\n");
|
|
1890
|
+
await send("Sent to Claude auth process.");
|
|
1891
|
+
} catch (e) {
|
|
1892
|
+
pendingClaudeAuthProcess = null;
|
|
1893
|
+
pendingClaudeAuthLabel = null;
|
|
1894
|
+
await send(`Could not send to Claude auth process: ${redactSensitive(e.message)}`);
|
|
1895
|
+
}
|
|
1896
|
+
return;
|
|
1897
|
+
}
|
|
1898
|
+
|
|
1550
1899
|
// Handle onboarding
|
|
1551
1900
|
if (!isOnboarded() && onboardingStep) {
|
|
1552
1901
|
await handleOnboarding(msg);
|
package/bot.js
CHANGED
|
@@ -248,6 +248,9 @@ bot.setMyCommands([
|
|
|
248
248
|
{ command: "cursor", description: "Switch to Cursor Agent backend" },
|
|
249
249
|
{ command: "claude", description: "Switch to Claude Code backend" },
|
|
250
250
|
{ command: "backend", description: "Show/switch active backend" },
|
|
251
|
+
{ command: "auth_status", description: "Check Claude Code auth" },
|
|
252
|
+
{ command: "login", description: "Start Claude Code login" },
|
|
253
|
+
{ command: "setup_token", description: "Create Claude OAuth token" },
|
|
251
254
|
{ command: "stop", description: "Cancel running task" },
|
|
252
255
|
{ command: "end", description: "End current session" },
|
|
253
256
|
{ command: "version", description: "Show current version" },
|
|
@@ -359,7 +362,10 @@ let messageQueue = [];
|
|
|
359
362
|
let activeCrons = new Map();
|
|
360
363
|
let pendingVaultUnlock = false; // Waiting for password
|
|
361
364
|
let pendingVaultAction = null; // What to do after unlock
|
|
365
|
+
let pendingClaudeAuthProcess = null;
|
|
366
|
+
let pendingClaudeAuthLabel = null;
|
|
362
367
|
let isFirstMessage = !lastSessionId; // Track if this is first message in session
|
|
368
|
+
let lastInputWasVoice = false; // Reply with voice when input was voice
|
|
363
369
|
|
|
364
370
|
let settings = savedState.settings || {
|
|
365
371
|
model: null, effort: null, budget: null, permissionMode: null, worktree: false, backend: "claude",
|
|
@@ -722,11 +728,246 @@ function transcribeAudio(oggPath) {
|
|
|
722
728
|
.join(" ").trim();
|
|
723
729
|
}
|
|
724
730
|
|
|
731
|
+
// ── Text-to-Speech ────────────────────────────────────────────────
|
|
732
|
+
|
|
733
|
+
const TTS_CMD = process.platform === "darwin" ? "say" : null;
|
|
734
|
+
|
|
735
|
+
function textToVoice(text) {
|
|
736
|
+
if (!TTS_CMD || !FFMPEG) return null;
|
|
737
|
+
try {
|
|
738
|
+
// Strip markdown formatting for cleaner speech
|
|
739
|
+
const clean = text.replace(/[*_`#>\[\]()]/g, "").replace(/\n{2,}/g, ". ").replace(/\n/g, " ").trim();
|
|
740
|
+
if (!clean) return null;
|
|
741
|
+
const aiffPath = path.join(TEMP_DIR, `tts-${Date.now()}.aiff`);
|
|
742
|
+
const oggPath = aiffPath.replace(".aiff", ".ogg");
|
|
743
|
+
execSync(`${TTS_CMD} ${JSON.stringify(clean)} -o "${aiffPath}"`, { timeout: 30000 });
|
|
744
|
+
execSync(`"${FFMPEG}" -i "${aiffPath}" -c:a libopus -y "${oggPath}" 2>/dev/null`, { timeout: 30000 });
|
|
745
|
+
try { fs.unlinkSync(aiffPath); } catch (e) {}
|
|
746
|
+
return oggPath;
|
|
747
|
+
} catch (e) {
|
|
748
|
+
console.error("TTS error:", e.message);
|
|
749
|
+
return null;
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
async function sendVoice(oggPath) {
|
|
754
|
+
try {
|
|
755
|
+
await bot.sendVoice(CHAT_ID, oggPath);
|
|
756
|
+
try { fs.unlinkSync(oggPath); } catch (e) {}
|
|
757
|
+
return true;
|
|
758
|
+
} catch (e) {
|
|
759
|
+
console.error("Send voice error:", e.message);
|
|
760
|
+
try { fs.unlinkSync(oggPath); } catch (e2) {}
|
|
761
|
+
return false;
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
|
|
725
765
|
// Delete a message (used for vault password cleanup)
|
|
726
766
|
async function deleteMessage(msgId) {
|
|
727
767
|
try { await bot.deleteMessage(CHAT_ID, msgId); } catch (e) { /* ignore */ }
|
|
728
768
|
}
|
|
729
769
|
|
|
770
|
+
|
|
771
|
+
// ── Claude Auth Helpers ─────────────────────────────────────────────
|
|
772
|
+
|
|
773
|
+
const CLAUDE_OAUTH_TOKEN_KEY = "CLAUDE_CODE_OAUTH_TOKEN";
|
|
774
|
+
const CLAUDE_OAUTH_VAULT_KEY = "claude_oauth_token";
|
|
775
|
+
|
|
776
|
+
function redactSensitive(value) {
|
|
777
|
+
return String(value || "")
|
|
778
|
+
.replace(/sk-ant-[A-Za-z0-9._-]+/g, "[REDACTED_TOKEN]")
|
|
779
|
+
.replace(/(Bearer\s+)[A-Za-z0-9._=-]+/gi, "$1[REDACTED_TOKEN]")
|
|
780
|
+
.replace(/(CLAUDE_CODE_OAUTH_TOKEN\s*=\s*)\S+/gi, "$1[REDACTED_TOKEN]")
|
|
781
|
+
.replace(/([?&](?:token|access_token|refresh_token)=)[^\s&]+/gi, "$1[REDACTED]");
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
function looksLikeClaudeToken(value) {
|
|
785
|
+
const text = String(value || "").trim();
|
|
786
|
+
return /^sk-ant-[A-Za-z0-9._-]+$/.test(text) || text.length >= 80 && /^[A-Za-z0-9._=-]+$/.test(text);
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
function getClaudeOAuthToken() {
|
|
790
|
+
if (config[CLAUDE_OAUTH_TOKEN_KEY]) return { value: config[CLAUDE_OAUTH_TOKEN_KEY], source: ".env" };
|
|
791
|
+
if (process.env.CLAUDE_CODE_OAUTH_TOKEN) return { value: process.env.CLAUDE_CODE_OAUTH_TOKEN, source: "process env" };
|
|
792
|
+
if (vault.isUnlocked()) {
|
|
793
|
+
const value = vault.get(CLAUDE_OAUTH_VAULT_KEY) || vault.get(CLAUDE_OAUTH_TOKEN_KEY);
|
|
794
|
+
if (value) return { value, source: "vault" };
|
|
795
|
+
}
|
|
796
|
+
return { value: null, source: null };
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
function claudeSubprocessEnv() {
|
|
800
|
+
const env = { ...process.env, PATH: FULL_PATH, HOME: process.env.HOME };
|
|
801
|
+
const token = getClaudeOAuthToken().value;
|
|
802
|
+
if (token) env.CLAUDE_CODE_OAUTH_TOKEN = token;
|
|
803
|
+
return env;
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
function saveClaudeOAuthToken(token) {
|
|
807
|
+
const clean = String(token || "").trim();
|
|
808
|
+
if (!clean) return false;
|
|
809
|
+
saveEnvKey(CLAUDE_OAUTH_TOKEN_KEY, clean);
|
|
810
|
+
config[CLAUDE_OAUTH_TOKEN_KEY] = clean;
|
|
811
|
+
process.env.CLAUDE_CODE_OAUTH_TOKEN = clean;
|
|
812
|
+
if (vault.isUnlocked()) vault.set(CLAUDE_OAUTH_VAULT_KEY, clean);
|
|
813
|
+
return true;
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
function clearClaudeOAuthToken() {
|
|
817
|
+
saveEnvKey(CLAUDE_OAUTH_TOKEN_KEY, "");
|
|
818
|
+
config[CLAUDE_OAUTH_TOKEN_KEY] = "";
|
|
819
|
+
delete process.env.CLAUDE_CODE_OAUTH_TOKEN;
|
|
820
|
+
if (vault.isUnlocked()) {
|
|
821
|
+
vault.remove(CLAUDE_OAUTH_VAULT_KEY);
|
|
822
|
+
vault.remove(CLAUDE_OAUTH_TOKEN_KEY);
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
function extractClaudeToken(text) {
|
|
827
|
+
const match = String(text || "").match(/sk-ant-[A-Za-z0-9._-]+/);
|
|
828
|
+
return match ? match[0] : null;
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
function extractUrls(text) {
|
|
832
|
+
return [...String(text || "").matchAll(/https?:\/\/[^\s)]+/g)].map((m) => m[0]);
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
|
|
836
|
+
function isClaudeAuthErrorText(text) {
|
|
837
|
+
const lower = String(text || "").toLowerCase();
|
|
838
|
+
return lower.includes("unauthorized") ||
|
|
839
|
+
lower.includes("not logged in") ||
|
|
840
|
+
lower.includes("login required") ||
|
|
841
|
+
lower.includes("reauthenticate") ||
|
|
842
|
+
lower.includes("re-authenticate") ||
|
|
843
|
+
lower.includes("authentication failed") ||
|
|
844
|
+
lower.includes("auth failed") ||
|
|
845
|
+
lower.includes("invalid api key") ||
|
|
846
|
+
lower.includes("api key") && lower.includes("invalid") ||
|
|
847
|
+
lower.includes("keychain") && (lower.includes("unlock") || lower.includes("could not") || lower.includes("failed")) ||
|
|
848
|
+
lower.includes("security unlock-keychain");
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
function claudeAuthRecoveryMessage(reason = "Claude Code authentication failed") {
|
|
852
|
+
const tokenInfo = getClaudeOAuthToken();
|
|
853
|
+
const tokenLine = tokenInfo.value
|
|
854
|
+
? `Stored OAuth token: yes (${tokenInfo.source})`
|
|
855
|
+
: "Stored OAuth token: no";
|
|
856
|
+
return [
|
|
857
|
+
`Claude auth needs attention: ${redactSensitive(reason)}`,
|
|
858
|
+
"",
|
|
859
|
+
tokenLine,
|
|
860
|
+
"",
|
|
861
|
+
"Try one of these from Telegram:",
|
|
862
|
+
"1. /auth_status — check what Claude sees",
|
|
863
|
+
"2. /setup_token — create a long-lived Claude Code token, then store it",
|
|
864
|
+
"3. /use_oauth_token — paste the token securely if you already generated one",
|
|
865
|
+
"4. /login — interactive Claude.ai browser login fallback",
|
|
866
|
+
"",
|
|
867
|
+
"If this is a macOS Keychain problem after SSH/reboot, run on the Mac:",
|
|
868
|
+
"security unlock-keychain",
|
|
869
|
+
"",
|
|
870
|
+
"Recommended for this bot: /setup_token + /use_oauth_token so launchd does not depend on Keychain."
|
|
871
|
+
].join("\n");
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
function preflightClaudeAuthMessage() {
|
|
875
|
+
if (settings.backend === "cursor") return null;
|
|
876
|
+
if (getClaudeOAuthToken().value) return null;
|
|
877
|
+
try {
|
|
878
|
+
const output = execSync(`"${CLAUDE_PATH}" auth status`, {
|
|
879
|
+
cwd: process.env.HOME || require("os").homedir(),
|
|
880
|
+
env: claudeSubprocessEnv(),
|
|
881
|
+
encoding: "utf8",
|
|
882
|
+
timeout: 10000,
|
|
883
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
884
|
+
});
|
|
885
|
+
if (isClaudeAuthErrorText(output)) return claudeAuthRecoveryMessage(output.trim().slice(-500));
|
|
886
|
+
return null;
|
|
887
|
+
} catch (e) {
|
|
888
|
+
const output = `${e.stdout || ""}\n${e.stderr || ""}\n${e.message || ""}`;
|
|
889
|
+
if (isClaudeAuthErrorText(output)) return claudeAuthRecoveryMessage(output.trim().slice(-500));
|
|
890
|
+
return null;
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
|
|
895
|
+
function summarizeClaudeAuthStatus(output, exitCode, tokenInfo) {
|
|
896
|
+
const text = String(output || "");
|
|
897
|
+
const lower = text.toLowerCase();
|
|
898
|
+
const loggedOut = /not (logged in|authenticated)|unauthenticated|no auth|login required/.test(lower);
|
|
899
|
+
const loggedIn = !loggedOut && (exitCode === 0 || /logged in|authenticated|claude\.ai|anthropic/.test(lower));
|
|
900
|
+
const email = (text.match(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/i) || [null])[0];
|
|
901
|
+
const provider = /claude\.ai|claudeai/.test(lower) ? "Claude.ai" : (/anthropic/.test(lower) ? "Anthropic" : "unknown");
|
|
902
|
+
let method = tokenInfo.value ? `OAuth token (${tokenInfo.source})` : "unknown";
|
|
903
|
+
if (/api key|apikey/.test(lower)) method = "API key";
|
|
904
|
+
else if (/oauth|claude\.ai|claudeai/.test(lower)) method = tokenInfo.value ? `OAuth token (${tokenInfo.source})` : "OAuth/Claude.ai";
|
|
905
|
+
return [
|
|
906
|
+
`Logged in: ${loggedIn ? "yes" : (loggedOut ? "no" : "unknown")}`,
|
|
907
|
+
`Auth method: ${method}`,
|
|
908
|
+
`Email: ${email || "unknown"}`,
|
|
909
|
+
`Provider: ${provider}`,
|
|
910
|
+
];
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
async function runClaudeAuthCommand(args, label, opts = {}) {
|
|
914
|
+
if (pendingClaudeAuthProcess) {
|
|
915
|
+
await send(`Another Claude auth flow is already running (${pendingClaudeAuthLabel}). Send the requested code/token or wait for it to finish.`);
|
|
916
|
+
return;
|
|
917
|
+
}
|
|
918
|
+
await send(`${label} started...`);
|
|
919
|
+
const proc = spawn(CLAUDE_PATH, args, {
|
|
920
|
+
cwd: process.env.HOME || require("os").homedir(),
|
|
921
|
+
env: claudeSubprocessEnv(),
|
|
922
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
923
|
+
});
|
|
924
|
+
pendingClaudeAuthProcess = proc;
|
|
925
|
+
pendingClaudeAuthLabel = label;
|
|
926
|
+
let output = "";
|
|
927
|
+
let sentUrls = new Set();
|
|
928
|
+
let tokenStored = false;
|
|
929
|
+
let lastSnippetAt = 0;
|
|
930
|
+
|
|
931
|
+
const handleChunk = async (chunk) => {
|
|
932
|
+
output += chunk;
|
|
933
|
+
const token = opts.captureToken ? extractClaudeToken(chunk) || extractClaudeToken(output) : null;
|
|
934
|
+
if (token && !tokenStored) {
|
|
935
|
+
tokenStored = saveClaudeOAuthToken(token);
|
|
936
|
+
await send(tokenStored ? "Claude OAuth token captured and stored. I did not print it." : "Claude OAuth token appeared, but I could not store it.");
|
|
937
|
+
}
|
|
938
|
+
for (const url of extractUrls(chunk)) {
|
|
939
|
+
if (!sentUrls.has(url)) {
|
|
940
|
+
sentUrls.add(url);
|
|
941
|
+
await send(`${label} URL:\n${redactSensitive(url)}\n\nOpen it, complete the flow, then paste any code Claude asks for here.`);
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
const redacted = redactSensitive(chunk).trim();
|
|
945
|
+
const now = Date.now();
|
|
946
|
+
if (redacted && now - lastSnippetAt > 3000 && !looksLikeClaudeToken(redacted)) {
|
|
947
|
+
lastSnippetAt = now;
|
|
948
|
+
await send(redacted.length > 1200 ? redacted.slice(-1200) : redacted);
|
|
949
|
+
}
|
|
950
|
+
};
|
|
951
|
+
|
|
952
|
+
proc.stdout.on("data", (d) => handleChunk(d.toString()).catch((e) => console.error("Auth output error:", e.message)));
|
|
953
|
+
proc.stderr.on("data", (d) => handleChunk(d.toString()).catch((e) => console.error("Auth stderr error:", e.message)));
|
|
954
|
+
proc.on("close", async (code) => {
|
|
955
|
+
pendingClaudeAuthProcess = null;
|
|
956
|
+
pendingClaudeAuthLabel = null;
|
|
957
|
+
const token = opts.captureToken ? extractClaudeToken(output) : null;
|
|
958
|
+
if (token && !tokenStored) tokenStored = saveClaudeOAuthToken(token);
|
|
959
|
+
const final = redactSensitive(output.trim());
|
|
960
|
+
if (tokenStored) await send(`${label} finished. OAuth token stored for launchd/non-interactive Claude runs.`);
|
|
961
|
+
else if (final) await send(`${label} finished (exit ${code}).\n\n${final.slice(-2500)}`);
|
|
962
|
+
else await send(`${label} finished (exit ${code}).`);
|
|
963
|
+
});
|
|
964
|
+
proc.on("error", async (err) => {
|
|
965
|
+
pendingClaudeAuthProcess = null;
|
|
966
|
+
pendingClaudeAuthLabel = null;
|
|
967
|
+
await send(`${label} failed: ${redactSensitive(err.message)}`);
|
|
968
|
+
});
|
|
969
|
+
}
|
|
970
|
+
|
|
730
971
|
// ── Claude Runner ───────────────────────────────────────────────────
|
|
731
972
|
|
|
732
973
|
function parseStreamEvents(data) {
|
|
@@ -781,6 +1022,12 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
|
|
|
781
1022
|
return;
|
|
782
1023
|
}
|
|
783
1024
|
|
|
1025
|
+
const authPreflight = preflightClaudeAuthMessage();
|
|
1026
|
+
if (authPreflight) {
|
|
1027
|
+
await send(authPreflight, { replyTo: replyToMsgId });
|
|
1028
|
+
return;
|
|
1029
|
+
}
|
|
1030
|
+
|
|
784
1031
|
bot.sendChatAction(CHAT_ID, "typing");
|
|
785
1032
|
statusMessageId = null;
|
|
786
1033
|
streamBuffer = "";
|
|
@@ -793,7 +1040,7 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
|
|
|
793
1040
|
const binaryPath = getActiveBinary();
|
|
794
1041
|
const proc = spawn(binaryPath, args, {
|
|
795
1042
|
cwd,
|
|
796
|
-
env:
|
|
1043
|
+
env: claudeSubprocessEnv(),
|
|
797
1044
|
stdio: ["ignore", "pipe", "pipe"],
|
|
798
1045
|
detached: process.platform !== "win32",
|
|
799
1046
|
});
|
|
@@ -927,7 +1174,7 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
|
|
|
927
1174
|
proc.stderr.on("data", (d) => {
|
|
928
1175
|
const chunk = d.toString();
|
|
929
1176
|
stderrBuffer += chunk;
|
|
930
|
-
console.error("STDERR:", chunk);
|
|
1177
|
+
console.error("STDERR:", redactSensitive(chunk));
|
|
931
1178
|
});
|
|
932
1179
|
|
|
933
1180
|
proc.on("close", async (code) => {
|
|
@@ -935,17 +1182,18 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
|
|
|
935
1182
|
clearTimeout(streamInterval); streamInterval = null;
|
|
936
1183
|
clearTimeout(processTimeout);
|
|
937
1184
|
|
|
938
|
-
// Check for auth errors in stderr
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
1185
|
+
// Check for auth errors in stderr and give actionable Telegram recovery steps.
|
|
1186
|
+
if (settings.backend !== "cursor" && isClaudeAuthErrorText(stderrBuffer)) {
|
|
1187
|
+
await send(claudeAuthRecoveryMessage(stderrBuffer.trim().slice(-800)), { replyTo: replyToMsgId });
|
|
1188
|
+
return;
|
|
1189
|
+
}
|
|
1190
|
+
if (settings.backend === "cursor" && isClaudeAuthErrorText(stderrBuffer)) {
|
|
1191
|
+
await send("Cursor authentication error. Run `agent login` on this machine, then retry.", { replyTo: replyToMsgId });
|
|
944
1192
|
return;
|
|
945
1193
|
}
|
|
946
1194
|
|
|
947
1195
|
try {
|
|
948
|
-
const finalText = assistantText || "(no output)";
|
|
1196
|
+
const finalText = redactSensitive(assistantText || "(no output)");
|
|
949
1197
|
const chunks = splitMessage(finalText);
|
|
950
1198
|
const firstChunk = chunks[0];
|
|
951
1199
|
|
|
@@ -964,6 +1212,13 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
|
|
|
964
1212
|
}
|
|
965
1213
|
}
|
|
966
1214
|
if (code !== 0 && code !== null) await send(`Exit code: ${code}`);
|
|
1215
|
+
|
|
1216
|
+
// Send voice reply if input was a voice note
|
|
1217
|
+
if (lastInputWasVoice && TTS_CMD) {
|
|
1218
|
+
lastInputWasVoice = false;
|
|
1219
|
+
const voicePath = textToVoice(finalText);
|
|
1220
|
+
if (voicePath) await sendVoice(voicePath);
|
|
1221
|
+
}
|
|
967
1222
|
} catch (e) {
|
|
968
1223
|
console.error("Final message delivery failed:", e.message);
|
|
969
1224
|
await send("Task completed but failed to deliver the response. Send /continue to see the result.");
|
|
@@ -998,13 +1253,13 @@ async function runClaudeSilent(prompt, cwd, label) {
|
|
|
998
1253
|
"--append-system-prompt", buildSystemPrompt(),
|
|
999
1254
|
"--dangerously-skip-permissions", prompt];
|
|
1000
1255
|
const proc = spawn(CLAUDE_PATH, args, {
|
|
1001
|
-
cwd, env:
|
|
1256
|
+
cwd, env: claudeSubprocessEnv(),
|
|
1002
1257
|
stdio: ["ignore", "pipe", "pipe"],
|
|
1003
1258
|
});
|
|
1004
1259
|
let output = "";
|
|
1005
1260
|
proc.stdout.on("data", (d) => { output += d.toString(); });
|
|
1006
1261
|
proc.on("close", async () => {
|
|
1007
|
-
const chunks = splitMessage(`Cron: ${label}\n\n${output.trim() || "(no output)"}`);
|
|
1262
|
+
const chunks = splitMessage(`Cron: ${label}\n\n${redactSensitive(output.trim() || "(no output)")}`);
|
|
1008
1263
|
for (const c of chunks) await send(c);
|
|
1009
1264
|
resolve();
|
|
1010
1265
|
});
|
|
@@ -1104,6 +1359,7 @@ bot.onText(/\/help/, (msg) => {
|
|
|
1104
1359
|
"Session: /session /sessions /projects /continue /status /stop /end",
|
|
1105
1360
|
"Settings: /model /effort /budget /plan /compact /worktree /mode",
|
|
1106
1361
|
"Automation: /cron /vault /soul",
|
|
1362
|
+
"Claude auth: /auth_status /login /setup_token /use_oauth_token /clear_oauth_token",
|
|
1107
1363
|
"System: /restart /upgrade",
|
|
1108
1364
|
"",
|
|
1109
1365
|
"Send text, voice, photos, or files.",
|
|
@@ -1354,6 +1610,64 @@ bot.onText(/\/soul$/, async (msg) => {
|
|
|
1354
1610
|
await send(`Edit: ${SOUL_FILE}\nOr tell me what to change and I'll update it.`);
|
|
1355
1611
|
});
|
|
1356
1612
|
|
|
1613
|
+
// ── Claude Auth Commands ────────────────────────────────────────────
|
|
1614
|
+
|
|
1615
|
+
bot.onText(/\/(?:auth_status|auth status)$/, async (msg) => {
|
|
1616
|
+
if (!isAuthorized(msg)) return;
|
|
1617
|
+
const tokenInfo = getClaudeOAuthToken();
|
|
1618
|
+
const proc = spawn(CLAUDE_PATH, ["auth", "status"], {
|
|
1619
|
+
cwd: process.env.HOME || require("os").homedir(),
|
|
1620
|
+
env: claudeSubprocessEnv(),
|
|
1621
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
1622
|
+
});
|
|
1623
|
+
let output = "";
|
|
1624
|
+
proc.stdout.on("data", (d) => { output += d.toString(); });
|
|
1625
|
+
proc.stderr.on("data", (d) => { output += d.toString(); });
|
|
1626
|
+
proc.on("close", async (code) => {
|
|
1627
|
+
const clean = redactSensitive(output.trim()) || "(no output)";
|
|
1628
|
+
await send([
|
|
1629
|
+
`Claude auth status: exit ${code}`,
|
|
1630
|
+
...summarizeClaudeAuthStatus(output, code, tokenInfo),
|
|
1631
|
+
`Bot OAuth token: ${tokenInfo.value ? "configured via " + tokenInfo.source : "not configured"}`,
|
|
1632
|
+
`Vault: ${vault.isUnlocked() ? "unlocked" : "locked"}`,
|
|
1633
|
+
"",
|
|
1634
|
+
clean.slice(-2500),
|
|
1635
|
+
].join("\n"));
|
|
1636
|
+
});
|
|
1637
|
+
proc.on("error", async (err) => send(`Claude auth status failed: ${redactSensitive(err.message)}`));
|
|
1638
|
+
});
|
|
1639
|
+
|
|
1640
|
+
bot.onText(/\/login$/, async (msg) => {
|
|
1641
|
+
if (!isAuthorized(msg)) return;
|
|
1642
|
+
await runClaudeAuthCommand(["auth", "login", "--claudeai", "--email", "sumeet@inet.africa"], "Claude login");
|
|
1643
|
+
});
|
|
1644
|
+
|
|
1645
|
+
bot.onText(/\/setup_token$/, async (msg) => {
|
|
1646
|
+
if (!isAuthorized(msg)) return;
|
|
1647
|
+
await runClaudeAuthCommand(["setup-token"], "Claude setup-token", { captureToken: true });
|
|
1648
|
+
});
|
|
1649
|
+
|
|
1650
|
+
bot.onText(/\/use_oauth_token(?:\s+(.+))?$/, async (msg, match) => {
|
|
1651
|
+
if (!isAuthorized(msg)) return;
|
|
1652
|
+
const token = (match[1] || "").trim();
|
|
1653
|
+
await deleteMessage(msg.message_id);
|
|
1654
|
+
if (!token) {
|
|
1655
|
+
pendingClaudeAuthProcess = { stdin: { write: (value) => saveClaudeOAuthToken(value.trim()) } };
|
|
1656
|
+
pendingClaudeAuthLabel = "manual OAuth token save";
|
|
1657
|
+
await send("Send the Claude OAuth token in your next message. I'll delete it and store it without echoing it.");
|
|
1658
|
+
return;
|
|
1659
|
+
}
|
|
1660
|
+
if (!looksLikeClaudeToken(token)) return send("That doesn't look like a Claude OAuth token. Not saved.");
|
|
1661
|
+
saveClaudeOAuthToken(token);
|
|
1662
|
+
await send(`Claude OAuth token stored in .env${vault.isUnlocked() ? " and vault" : ""}. Restart the bot so launchd picks it up, or use /restart.`);
|
|
1663
|
+
});
|
|
1664
|
+
|
|
1665
|
+
bot.onText(/\/clear_oauth_token$/, async (msg) => {
|
|
1666
|
+
if (!isAuthorized(msg)) return;
|
|
1667
|
+
clearClaudeOAuthToken();
|
|
1668
|
+
await send("Claude OAuth token cleared from .env/process" + (vault.isUnlocked() ? " and vault." : ". Unlock vault and run again if you also stored it there."));
|
|
1669
|
+
});
|
|
1670
|
+
|
|
1357
1671
|
// ── /vault with password protection ─────────────────────────────────
|
|
1358
1672
|
|
|
1359
1673
|
bot.onText(/\/vault$/, async (msg) => {
|
|
@@ -1527,6 +1841,7 @@ bot.on("voice", async (msg) => {
|
|
|
1527
1841
|
try { fs.unlinkSync(oggPath); } catch (e) {}
|
|
1528
1842
|
if (!transcript) return send("Couldn't transcribe. Try typing it.");
|
|
1529
1843
|
await send(`Heard: "${transcript}"`, { replyTo: msg.message_id });
|
|
1844
|
+
lastInputWasVoice = true;
|
|
1530
1845
|
await runClaude(transcript, currentSession.dir, msg.message_id);
|
|
1531
1846
|
} catch (err) { await send(`Voice failed: ${err.message}`); }
|
|
1532
1847
|
});
|
|
@@ -1597,6 +1912,29 @@ bot.on("message", async (msg) => {
|
|
|
1597
1912
|
if (msg.voice || msg.audio || msg.photo || msg.document || msg.video || msg.sticker) return;
|
|
1598
1913
|
if (isDuplicate(msg.message_id)) return;
|
|
1599
1914
|
|
|
1915
|
+
// Handle pending Claude auth/token paste-back
|
|
1916
|
+
if (pendingClaudeAuthProcess) {
|
|
1917
|
+
const text = msg.text.trim();
|
|
1918
|
+
await deleteMessage(msg.message_id);
|
|
1919
|
+
if (pendingClaudeAuthLabel === "manual OAuth token save") {
|
|
1920
|
+
pendingClaudeAuthProcess = null;
|
|
1921
|
+
pendingClaudeAuthLabel = null;
|
|
1922
|
+
if (!looksLikeClaudeToken(text)) { await send("That doesn't look like a Claude OAuth token. Not saved."); return; }
|
|
1923
|
+
saveClaudeOAuthToken(text);
|
|
1924
|
+
await send(`Claude OAuth token stored in .env${vault.isUnlocked() ? " and vault" : ""}. Restart the bot so launchd picks it up, or use /restart.`);
|
|
1925
|
+
return;
|
|
1926
|
+
}
|
|
1927
|
+
try {
|
|
1928
|
+
pendingClaudeAuthProcess.stdin.write(text + "\n");
|
|
1929
|
+
await send("Sent to Claude auth process.");
|
|
1930
|
+
} catch (e) {
|
|
1931
|
+
pendingClaudeAuthProcess = null;
|
|
1932
|
+
pendingClaudeAuthLabel = null;
|
|
1933
|
+
await send(`Could not send to Claude auth process: ${redactSensitive(e.message)}`);
|
|
1934
|
+
}
|
|
1935
|
+
return;
|
|
1936
|
+
}
|
|
1937
|
+
|
|
1600
1938
|
// Handle onboarding
|
|
1601
1939
|
if (!isOnboarded() && onboardingStep) {
|
|
1602
1940
|
await handleOnboarding(msg);
|
package/package.json
CHANGED
package/setup.js
CHANGED
|
@@ -152,7 +152,9 @@ function findFfmpeg() { return findBinary("ffmpeg"); }
|
|
|
152
152
|
|
|
153
153
|
function findWhisperModel() {
|
|
154
154
|
const candidates = [
|
|
155
|
+
"/opt/homebrew/share/whisper-cpp/models/ggml-base.en.bin",
|
|
155
156
|
"/opt/homebrew/share/whisper-cpp/ggml-base.en.bin",
|
|
157
|
+
"/usr/local/share/whisper-cpp/models/ggml-base.en.bin",
|
|
156
158
|
"/usr/local/share/whisper-cpp/ggml-base.en.bin",
|
|
157
159
|
path.join(__dirname, "models", "ggml-base.en.bin"),
|
|
158
160
|
];
|
|
@@ -162,6 +164,79 @@ function findWhisperModel() {
|
|
|
162
164
|
return null;
|
|
163
165
|
}
|
|
164
166
|
|
|
167
|
+
async function installWhisper(platform) {
|
|
168
|
+
if (platform === "macos") {
|
|
169
|
+
console.log("\n Installing whisper-cpp via Homebrew...");
|
|
170
|
+
try {
|
|
171
|
+
execSync("brew install whisper-cpp", { stdio: "inherit", timeout: 300000 });
|
|
172
|
+
} catch (e) {
|
|
173
|
+
console.log(" Failed to install whisper-cpp. Install manually: brew install whisper-cpp");
|
|
174
|
+
return { cli: null, model: null };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const cli = findWhisper();
|
|
178
|
+
if (!cli) {
|
|
179
|
+
console.log(" whisper-cli not found after install.");
|
|
180
|
+
return { cli: null, model: null };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Download model
|
|
184
|
+
const modelDir = "/usr/local/share/whisper-cpp/models";
|
|
185
|
+
const modelPath = path.join(modelDir, "ggml-base.en.bin");
|
|
186
|
+
if (!fs.existsSync(modelPath)) {
|
|
187
|
+
console.log(" Downloading whisper base.en model (~142MB)...");
|
|
188
|
+
try {
|
|
189
|
+
fs.mkdirSync(modelDir, { recursive: true });
|
|
190
|
+
execSync(`curl -L -o "${modelPath}" "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base.en.bin"`, {
|
|
191
|
+
stdio: "inherit", timeout: 300000,
|
|
192
|
+
});
|
|
193
|
+
} catch (e) {
|
|
194
|
+
console.log(" Failed to download model. Voice notes will be disabled.");
|
|
195
|
+
return { cli, model: null };
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
console.log(" Whisper installed successfully!\n");
|
|
200
|
+
return { cli, model: modelPath };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (platform === "linux") {
|
|
204
|
+
console.log("\n On Linux, install whisper.cpp manually:");
|
|
205
|
+
console.log(" https://github.com/ggerganov/whisper.cpp#quick-start");
|
|
206
|
+
return { cli: null, model: null };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
console.log(" Whisper auto-install not supported on this platform.");
|
|
210
|
+
return { cli: null, model: null };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async function installFfmpeg(platform) {
|
|
214
|
+
if (platform === "macos") {
|
|
215
|
+
console.log("\n Installing FFmpeg via Homebrew...");
|
|
216
|
+
try {
|
|
217
|
+
execSync("brew install ffmpeg", { stdio: "inherit", timeout: 300000 });
|
|
218
|
+
} catch (e) {
|
|
219
|
+
console.log(" Failed to install FFmpeg. Install manually: brew install ffmpeg");
|
|
220
|
+
return null;
|
|
221
|
+
}
|
|
222
|
+
return findFfmpeg();
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (platform === "linux") {
|
|
226
|
+
console.log("\n Installing FFmpeg via apt...");
|
|
227
|
+
try {
|
|
228
|
+
execSync("sudo apt-get install -y ffmpeg", { stdio: "inherit", timeout: 300000 });
|
|
229
|
+
} catch (e) {
|
|
230
|
+
console.log(" Failed to install FFmpeg. Install manually: sudo apt-get install ffmpeg");
|
|
231
|
+
return null;
|
|
232
|
+
}
|
|
233
|
+
return findFfmpeg();
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
console.log(" FFmpeg auto-install not supported on this platform.");
|
|
237
|
+
return null;
|
|
238
|
+
}
|
|
239
|
+
|
|
165
240
|
// ── Daemon setup ───────────────────────────────────────────────────
|
|
166
241
|
|
|
167
242
|
async function setupDaemon(platform) {
|
|
@@ -470,13 +545,47 @@ async function main() {
|
|
|
470
545
|
const platform = detectPlatform();
|
|
471
546
|
console.log(` Platform: ${platform}`);
|
|
472
547
|
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
548
|
+
let ffmpegPath = findFfmpeg();
|
|
549
|
+
let whisperPath = findWhisper();
|
|
550
|
+
let whisperModel = findWhisperModel();
|
|
476
551
|
console.log(` FFmpeg: ${ffmpegPath || "not found (voice notes disabled)"}`);
|
|
477
552
|
console.log(` Whisper: ${whisperPath || "not found (voice notes disabled)"}`);
|
|
478
553
|
console.log("");
|
|
479
554
|
|
|
555
|
+
// Offer to install missing voice note dependencies
|
|
556
|
+
if (!ffmpegPath || !whisperPath) {
|
|
557
|
+
const installVoice = await ask(" Enable voice notes? This will install FFmpeg + Whisper (y/n) [y]: ");
|
|
558
|
+
if (installVoice.toLowerCase() !== "n") {
|
|
559
|
+
if (!ffmpegPath) {
|
|
560
|
+
ffmpegPath = await installFfmpeg(platform);
|
|
561
|
+
if (ffmpegPath) console.log(` FFmpeg: ${ffmpegPath}`);
|
|
562
|
+
}
|
|
563
|
+
if (!whisperPath) {
|
|
564
|
+
const result = await installWhisper(platform);
|
|
565
|
+
whisperPath = result.cli || "";
|
|
566
|
+
whisperModel = result.model || "";
|
|
567
|
+
if (whisperPath) console.log(` Whisper: ${whisperPath}`);
|
|
568
|
+
} else if (!whisperModel) {
|
|
569
|
+
// Whisper CLI exists but no model — download it
|
|
570
|
+
const modelDir = platform === "macos"
|
|
571
|
+
? (fs.existsSync("/opt/homebrew/bin") ? "/opt/homebrew/share/whisper-cpp/models" : "/usr/local/share/whisper-cpp/models")
|
|
572
|
+
: "/usr/local/share/whisper-cpp/models";
|
|
573
|
+
const modelPath = path.join(modelDir, "ggml-base.en.bin");
|
|
574
|
+
console.log(" Downloading whisper base.en model (~142MB)...");
|
|
575
|
+
try {
|
|
576
|
+
fs.mkdirSync(modelDir, { recursive: true });
|
|
577
|
+
execSync(`curl -L -o "${modelPath}" "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base.en.bin"`, {
|
|
578
|
+
stdio: "inherit", timeout: 300000,
|
|
579
|
+
});
|
|
580
|
+
whisperModel = modelPath;
|
|
581
|
+
} catch (e) {
|
|
582
|
+
console.log(" Failed to download model.");
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
console.log("");
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
480
589
|
state.data.claudePath = claudePath;
|
|
481
590
|
state.data.platform = platform;
|
|
482
591
|
state.data.ffmpegPath = ffmpegPath || "";
|