@gonzih/cc-discord 0.1.2 → 0.1.3

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.
@@ -0,0 +1,24 @@
1
+ /**
2
+ * OAuth token pool management.
3
+ *
4
+ * Supports CLAUDE_CODE_OAUTH_TOKENS (comma-separated list of tokens).
5
+ * Falls back to CLAUDE_CODE_OAUTH_TOKEN for single-token / backwards compat.
6
+ *
7
+ * cc-tg token pool rotates independently from cc-agent's pool. No coordination between them.
8
+ */
9
+ /**
10
+ * Load tokens from env vars. Called on startup; also re-callable in tests.
11
+ * Priority: CLAUDE_CODE_OAUTH_TOKENS > CLAUDE_CODE_OAUTH_TOKEN > (empty)
12
+ */
13
+ export declare function loadTokens(): string[];
14
+ /** Returns the current active token, or empty string if none configured. */
15
+ export declare function getCurrentToken(): string;
16
+ /**
17
+ * Advance to the next token (wraps around).
18
+ * Returns the new current token.
19
+ */
20
+ export declare function rotateToken(): string;
21
+ /** Zero-based index of the current token. */
22
+ export declare function getTokenIndex(): number;
23
+ /** Total number of tokens in the pool. */
24
+ export declare function getTokenCount(): number;
package/dist/tokens.js ADDED
@@ -0,0 +1,58 @@
1
+ /**
2
+ * OAuth token pool management.
3
+ *
4
+ * Supports CLAUDE_CODE_OAUTH_TOKENS (comma-separated list of tokens).
5
+ * Falls back to CLAUDE_CODE_OAUTH_TOKEN for single-token / backwards compat.
6
+ *
7
+ * cc-tg token pool rotates independently from cc-agent's pool. No coordination between them.
8
+ */
9
+ let tokens = [];
10
+ let currentIndex = 0;
11
+ let initialized = false;
12
+ /**
13
+ * Load tokens from env vars. Called on startup; also re-callable in tests.
14
+ * Priority: CLAUDE_CODE_OAUTH_TOKENS > CLAUDE_CODE_OAUTH_TOKEN > (empty)
15
+ */
16
+ export function loadTokens() {
17
+ const multi = process.env.CLAUDE_CODE_OAUTH_TOKENS;
18
+ if (multi) {
19
+ tokens = multi.split(",").map((t) => t.trim()).filter(Boolean);
20
+ }
21
+ else {
22
+ const single = process.env.CLAUDE_CODE_OAUTH_TOKEN;
23
+ tokens = single ? [single] : [];
24
+ }
25
+ currentIndex = 0;
26
+ initialized = true;
27
+ return tokens;
28
+ }
29
+ function ensureInitialized() {
30
+ if (!initialized)
31
+ loadTokens();
32
+ }
33
+ /** Returns the current active token, or empty string if none configured. */
34
+ export function getCurrentToken() {
35
+ ensureInitialized();
36
+ return tokens[currentIndex] ?? "";
37
+ }
38
+ /**
39
+ * Advance to the next token (wraps around).
40
+ * Returns the new current token.
41
+ */
42
+ export function rotateToken() {
43
+ ensureInitialized();
44
+ if (tokens.length === 0)
45
+ return "";
46
+ currentIndex = (currentIndex + 1) % tokens.length;
47
+ return tokens[currentIndex];
48
+ }
49
+ /** Zero-based index of the current token. */
50
+ export function getTokenIndex() {
51
+ ensureInitialized();
52
+ return currentIndex;
53
+ }
54
+ /** Total number of tokens in the pool. */
55
+ export function getTokenCount() {
56
+ ensureInitialized();
57
+ return tokens.length;
58
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Voice message transcription via whisper.cpp.
3
+ * Flow: Telegram OGG → ffmpeg convert to 16kHz WAV → whisper-cpp → text
4
+ */
5
+ /**
6
+ * Transcribe a voice message from a Telegram file URL.
7
+ * Returns the transcribed text, or throws if whisper/ffmpeg not available.
8
+ */
9
+ export declare function transcribeVoice(fileUrl: string): Promise<string>;
10
+ /**
11
+ * Check if voice transcription is available on this system.
12
+ */
13
+ export declare function isVoiceAvailable(): boolean;
package/dist/voice.js ADDED
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Voice message transcription via whisper.cpp.
3
+ * Flow: Telegram OGG → ffmpeg convert to 16kHz WAV → whisper-cpp → text
4
+ */
5
+ import { execFile } from "child_process";
6
+ import { promisify } from "util";
7
+ import { existsSync } from "fs";
8
+ import { unlink, readFile } from "fs/promises";
9
+ import { tmpdir } from "os";
10
+ import { join } from "path";
11
+ import https from "https";
12
+ import http from "http";
13
+ import { createWriteStream } from "fs";
14
+ const execFileAsync = promisify(execFile);
15
+ // Whisper model — small.en is fast and accurate enough for commands
16
+ // Falls back to base.en if small not found
17
+ const WHISPER_MODELS = [
18
+ "/opt/homebrew/share/whisper-cpp/ggml-small.en.bin",
19
+ "/opt/homebrew/share/whisper-cpp/ggml-small.bin",
20
+ "/opt/homebrew/share/whisper-cpp/ggml-base.en.bin",
21
+ "/opt/homebrew/share/whisper-cpp/ggml-base.bin",
22
+ // user-local
23
+ `${process.env.HOME}/.local/share/whisper-cpp/ggml-small.en.bin`,
24
+ `${process.env.HOME}/.local/share/whisper-cpp/ggml-base.en.bin`,
25
+ ];
26
+ const WHISPER_BIN_CANDIDATES = [
27
+ "/opt/homebrew/bin/whisper-cli", // whisper-cpp brew formula installs as whisper-cli
28
+ "/opt/homebrew/bin/whisper-cpp",
29
+ "/usr/local/bin/whisper-cli",
30
+ "/usr/local/bin/whisper-cpp",
31
+ "/opt/homebrew/bin/whisper",
32
+ ];
33
+ const FFMPEG_CANDIDATES = [
34
+ "/opt/homebrew/bin/ffmpeg",
35
+ "/usr/local/bin/ffmpeg",
36
+ "/usr/bin/ffmpeg",
37
+ ];
38
+ function findBin(candidates) {
39
+ for (const p of candidates) {
40
+ if (existsSync(p))
41
+ return p;
42
+ }
43
+ return null;
44
+ }
45
+ function findModel() {
46
+ for (const p of WHISPER_MODELS) {
47
+ if (existsSync(p))
48
+ return p;
49
+ }
50
+ return null;
51
+ }
52
+ function downloadFile(url, dest) {
53
+ return new Promise((resolve, reject) => {
54
+ const file = createWriteStream(dest);
55
+ const getter = url.startsWith("https") ? https : http;
56
+ getter.get(url, (res) => {
57
+ if (res.statusCode !== 200) {
58
+ reject(new Error(`HTTP ${res.statusCode} downloading ${url}`));
59
+ return;
60
+ }
61
+ res.pipe(file);
62
+ file.on("finish", () => file.close(() => resolve()));
63
+ file.on("error", reject);
64
+ }).on("error", reject);
65
+ });
66
+ }
67
+ /**
68
+ * Transcribe a voice message from a Telegram file URL.
69
+ * Returns the transcribed text, or throws if whisper/ffmpeg not available.
70
+ */
71
+ export async function transcribeVoice(fileUrl) {
72
+ const whisperBin = findBin(WHISPER_BIN_CANDIDATES);
73
+ if (!whisperBin)
74
+ throw new Error("whisper-cpp not found — install with: brew install whisper-cpp");
75
+ const ffmpegBin = findBin(FFMPEG_CANDIDATES);
76
+ if (!ffmpegBin)
77
+ throw new Error("ffmpeg not found — install with: brew install ffmpeg");
78
+ const model = findModel();
79
+ if (!model)
80
+ throw new Error("No whisper model found — run: whisper-cpp-download-ggml-model small.en");
81
+ const tmp = join(tmpdir(), `cc-tg-voice-${Date.now()}`);
82
+ const oggPath = `${tmp}.ogg`;
83
+ const wavPath = `${tmp}.wav`;
84
+ try {
85
+ // 1. Download OGG from Telegram
86
+ await downloadFile(fileUrl, oggPath);
87
+ // 2. Convert OGG → 16kHz mono WAV (whisper requirement)
88
+ await execFileAsync(ffmpegBin, [
89
+ "-y", "-i", oggPath,
90
+ "-ar", "16000",
91
+ "-ac", "1",
92
+ "-c:a", "pcm_s16le",
93
+ wavPath,
94
+ ]);
95
+ // 3. Run whisper-cpp
96
+ // --output-txt writes to ${wavPath}.txt (NOT stdout)
97
+ // -l auto fails with .en models — detect and use -l en instead
98
+ const isEnModel = model.includes(".en.");
99
+ const langArgs = isEnModel ? ["-l", "en"] : ["-l", "auto"];
100
+ try {
101
+ await execFileAsync(whisperBin, [
102
+ "-m", model,
103
+ "-f", wavPath,
104
+ "--no-timestamps",
105
+ ...langArgs,
106
+ "--output-txt", // writes to wavPath + ".txt"
107
+ ]);
108
+ }
109
+ catch (err) {
110
+ const msg = err instanceof Error ? err.message : String(err);
111
+ throw new Error(`whisper-cpp failed: ${msg}`);
112
+ }
113
+ // Read the output file whisper-cpp wrote
114
+ const txtPath = `${wavPath}.txt`;
115
+ let raw = "";
116
+ try {
117
+ raw = await readFile(txtPath, "utf-8");
118
+ }
119
+ catch {
120
+ throw new Error("whisper-cpp ran but produced no output file");
121
+ }
122
+ const text = raw
123
+ .replace(/\[BLANK_AUDIO\]/gi, "")
124
+ .replace(/\[.*?\]/g, "") // remove timestamp artifacts
125
+ .trim();
126
+ return text || "[empty transcription]";
127
+ }
128
+ finally {
129
+ // Cleanup temp files
130
+ await unlink(oggPath).catch(() => { });
131
+ await unlink(wavPath).catch(() => { });
132
+ await unlink(`${wavPath}.txt`).catch(() => { });
133
+ }
134
+ }
135
+ /**
136
+ * Check if voice transcription is available on this system.
137
+ */
138
+ export function isVoiceAvailable() {
139
+ return (findBin(WHISPER_BIN_CANDIDATES) !== null &&
140
+ findBin(FFMPEG_CANDIDATES) !== null &&
141
+ findModel() !== null);
142
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gonzih/cc-discord",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "Claude Code Discord bot — chat with Claude Code via Discord",
5
5
  "type": "module",
6
6
  "bin": {