@gonzih/cc-tg 0.1.8 → 0.2.1
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/dist/bot.d.ts +3 -0
- package/dist/bot.js +117 -3
- package/dist/claude.js +30 -1
- package/dist/voice.d.ts +13 -0
- package/dist/voice.js +124 -0
- package/package.json +1 -1
package/dist/bot.d.ts
CHANGED
|
@@ -15,11 +15,14 @@ export declare class CcTgBot {
|
|
|
15
15
|
constructor(opts: BotOptions);
|
|
16
16
|
private isAllowed;
|
|
17
17
|
private handleTelegram;
|
|
18
|
+
private handleVoice;
|
|
18
19
|
private getOrCreateSession;
|
|
19
20
|
private handleClaudeMessage;
|
|
20
21
|
private startTyping;
|
|
21
22
|
private stopTyping;
|
|
22
23
|
private flushPending;
|
|
24
|
+
private trackWrittenFiles;
|
|
25
|
+
private uploadMentionedFiles;
|
|
23
26
|
private extractToolName;
|
|
24
27
|
private killSession;
|
|
25
28
|
stop(): void;
|
package/dist/bot.js
CHANGED
|
@@ -3,7 +3,10 @@
|
|
|
3
3
|
* One ClaudeProcess per chat_id — sessions are isolated per user.
|
|
4
4
|
*/
|
|
5
5
|
import TelegramBot from "node-telegram-bot-api";
|
|
6
|
+
import { existsSync } from "fs";
|
|
7
|
+
import { resolve, basename } from "path";
|
|
6
8
|
import { ClaudeProcess, extractText } from "./claude.js";
|
|
9
|
+
import { transcribeVoice, isVoiceAvailable } from "./voice.js";
|
|
7
10
|
const FLUSH_DELAY_MS = 800; // debounce streaming chunks into one Telegram message
|
|
8
11
|
const TYPING_INTERVAL_MS = 4000; // re-send typing action before Telegram's 5s expiry
|
|
9
12
|
export class CcTgBot {
|
|
@@ -16,6 +19,7 @@ export class CcTgBot {
|
|
|
16
19
|
this.bot.on("message", (msg) => this.handleTelegram(msg));
|
|
17
20
|
this.bot.on("polling_error", (err) => console.error("[tg]", err.message));
|
|
18
21
|
console.log("cc-tg bot started");
|
|
22
|
+
console.log(`[voice] whisper available: ${isVoiceAvailable()}`);
|
|
19
23
|
}
|
|
20
24
|
isAllowed(userId) {
|
|
21
25
|
if (!this.opts.allowedUserIds?.length)
|
|
@@ -25,13 +29,18 @@ export class CcTgBot {
|
|
|
25
29
|
async handleTelegram(msg) {
|
|
26
30
|
const chatId = msg.chat.id;
|
|
27
31
|
const userId = msg.from?.id ?? chatId;
|
|
28
|
-
const text = msg.text?.trim();
|
|
29
|
-
if (!text)
|
|
30
|
-
return;
|
|
31
32
|
if (!this.isAllowed(userId)) {
|
|
32
33
|
await this.bot.sendMessage(chatId, "Not authorized.");
|
|
33
34
|
return;
|
|
34
35
|
}
|
|
36
|
+
// Voice message — transcribe then feed as text
|
|
37
|
+
if (msg.voice || msg.audio) {
|
|
38
|
+
await this.handleVoice(chatId, msg);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
const text = msg.text?.trim();
|
|
42
|
+
if (!text)
|
|
43
|
+
return;
|
|
35
44
|
// /start or /reset — kill existing session and ack
|
|
36
45
|
if (text === "/start" || text === "/reset") {
|
|
37
46
|
this.killSession(chatId);
|
|
@@ -61,6 +70,36 @@ export class CcTgBot {
|
|
|
61
70
|
this.killSession(chatId);
|
|
62
71
|
}
|
|
63
72
|
}
|
|
73
|
+
async handleVoice(chatId, msg) {
|
|
74
|
+
const fileId = msg.voice?.file_id ?? msg.audio?.file_id;
|
|
75
|
+
if (!fileId)
|
|
76
|
+
return;
|
|
77
|
+
console.log(`[voice:${chatId}] received voice message, transcribing...`);
|
|
78
|
+
this.bot.sendChatAction(chatId, "typing").catch(() => { });
|
|
79
|
+
try {
|
|
80
|
+
const fileLink = await this.bot.getFileLink(fileId);
|
|
81
|
+
const transcript = await transcribeVoice(fileLink);
|
|
82
|
+
console.log(`[voice:${chatId}] transcribed: ${transcript}`);
|
|
83
|
+
if (!transcript || transcript === "[empty transcription]") {
|
|
84
|
+
await this.bot.sendMessage(chatId, "Could not transcribe voice message.");
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
// Feed transcript into Claude as if user typed it
|
|
88
|
+
const session = this.getOrCreateSession(chatId);
|
|
89
|
+
try {
|
|
90
|
+
session.claude.sendPrompt(transcript);
|
|
91
|
+
this.startTyping(chatId, session);
|
|
92
|
+
}
|
|
93
|
+
catch (err) {
|
|
94
|
+
await this.bot.sendMessage(chatId, `Error sending to Claude: ${err.message}`);
|
|
95
|
+
this.killSession(chatId);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
catch (err) {
|
|
99
|
+
console.error(`[voice:${chatId}] error:`, err.message);
|
|
100
|
+
await this.bot.sendMessage(chatId, `Voice transcription failed: ${err.message}`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
64
103
|
getOrCreateSession(chatId) {
|
|
65
104
|
const existing = this.sessions.get(chatId);
|
|
66
105
|
if (existing && !existing.claude.exited)
|
|
@@ -74,6 +113,7 @@ export class CcTgBot {
|
|
|
74
113
|
pendingText: "",
|
|
75
114
|
flushTimer: null,
|
|
76
115
|
typingTimer: null,
|
|
116
|
+
writtenFiles: new Set(),
|
|
77
117
|
};
|
|
78
118
|
claude.on("message", (msg) => {
|
|
79
119
|
// Verbose logging — log every message type and subtype
|
|
@@ -85,6 +125,8 @@ export class CcTgBot {
|
|
|
85
125
|
if (toolName)
|
|
86
126
|
logParts.push(`tool=${toolName}`);
|
|
87
127
|
console.log(logParts.join(" "));
|
|
128
|
+
// Track files written by Write/Edit tool calls
|
|
129
|
+
this.trackWrittenFiles(msg, session, this.opts.cwd);
|
|
88
130
|
this.handleClaudeMessage(chatId, session, msg);
|
|
89
131
|
});
|
|
90
132
|
claude.on("stderr", (data) => {
|
|
@@ -149,6 +191,78 @@ export class CcTgBot {
|
|
|
149
191
|
this.bot.sendMessage(chatId, chunk).catch((err) => console.error(`[tg:${chatId}] send failed:`, err.message));
|
|
150
192
|
});
|
|
151
193
|
}
|
|
194
|
+
// Hybrid file upload: find files mentioned in result text that Claude actually wrote
|
|
195
|
+
this.uploadMentionedFiles(chatId, text, session);
|
|
196
|
+
}
|
|
197
|
+
trackWrittenFiles(msg, session, cwd) {
|
|
198
|
+
// Only look at assistant messages with tool_use blocks
|
|
199
|
+
if (msg.type !== "assistant")
|
|
200
|
+
return;
|
|
201
|
+
const message = msg.payload.message;
|
|
202
|
+
if (!message)
|
|
203
|
+
return;
|
|
204
|
+
const content = message.content;
|
|
205
|
+
if (!Array.isArray(content))
|
|
206
|
+
return;
|
|
207
|
+
for (const block of content) {
|
|
208
|
+
if (block.type !== "tool_use")
|
|
209
|
+
continue;
|
|
210
|
+
const name = block.name;
|
|
211
|
+
if (!["Write", "Edit", "NotebookEdit"].includes(name))
|
|
212
|
+
continue;
|
|
213
|
+
const input = block.input;
|
|
214
|
+
if (!input)
|
|
215
|
+
continue;
|
|
216
|
+
// Write tool uses file_path, Edit uses file_path
|
|
217
|
+
const filePath = input.file_path ?? input.path;
|
|
218
|
+
if (!filePath)
|
|
219
|
+
continue;
|
|
220
|
+
// Resolve relative paths against cwd
|
|
221
|
+
const resolved = filePath.startsWith("/")
|
|
222
|
+
? filePath
|
|
223
|
+
: resolve(cwd ?? process.cwd(), filePath);
|
|
224
|
+
console.log(`[claude:files] tracked written file: ${resolved}`);
|
|
225
|
+
session.writtenFiles.add(resolved);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
uploadMentionedFiles(chatId, resultText, session) {
|
|
229
|
+
if (session.writtenFiles.size === 0)
|
|
230
|
+
return;
|
|
231
|
+
// Extract file path candidates from result text
|
|
232
|
+
// Match: /absolute/path/file.ext or relative like ./foo/bar.csv or just foo.pdf
|
|
233
|
+
const pathPattern = /(?:^|[\s`'"(])(\/?[\w.\-/]+\.[\w]{1,10})(?:[\s`'")\n]|$)/gm;
|
|
234
|
+
const candidates = new Set();
|
|
235
|
+
let match;
|
|
236
|
+
while ((match = pathPattern.exec(resultText)) !== null) {
|
|
237
|
+
candidates.add(match[1]);
|
|
238
|
+
}
|
|
239
|
+
const toUpload = [];
|
|
240
|
+
for (const candidate of candidates) {
|
|
241
|
+
// Try as-is (absolute), or resolve against cwd
|
|
242
|
+
const resolved = candidate.startsWith("/")
|
|
243
|
+
? candidate
|
|
244
|
+
: resolve(this.opts.cwd ?? process.cwd(), candidate);
|
|
245
|
+
if (session.writtenFiles.has(resolved) && existsSync(resolved)) {
|
|
246
|
+
toUpload.push(resolved);
|
|
247
|
+
}
|
|
248
|
+
else {
|
|
249
|
+
// Also check by basename — result might mention just the filename
|
|
250
|
+
for (const written of session.writtenFiles) {
|
|
251
|
+
if (basename(written) === basename(candidate) && existsSync(written)) {
|
|
252
|
+
toUpload.push(written);
|
|
253
|
+
break;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
// Deduplicate
|
|
259
|
+
const unique = [...new Set(toUpload)];
|
|
260
|
+
for (const filePath of unique) {
|
|
261
|
+
console.log(`[claude:files] uploading to telegram: ${filePath}`);
|
|
262
|
+
this.bot.sendDocument(chatId, filePath).catch((err) => console.error(`[tg:${chatId}] sendDocument failed for ${filePath}:`, err.message));
|
|
263
|
+
}
|
|
264
|
+
// Clear written files for next turn
|
|
265
|
+
session.writtenFiles.clear();
|
|
152
266
|
}
|
|
153
267
|
extractToolName(msg) {
|
|
154
268
|
const message = msg.payload.message;
|
package/dist/claude.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { spawn } from "child_process";
|
|
7
7
|
import { EventEmitter } from "events";
|
|
8
|
+
import { existsSync } from "fs";
|
|
8
9
|
export class ClaudeProcess extends EventEmitter {
|
|
9
10
|
proc;
|
|
10
11
|
buffer = "";
|
|
@@ -37,7 +38,9 @@ export class ClaudeProcess extends EventEmitter {
|
|
|
37
38
|
delete env.ANTHROPIC_API_KEY;
|
|
38
39
|
}
|
|
39
40
|
}
|
|
40
|
-
|
|
41
|
+
// Resolve claude binary — check common install locations if not in PATH
|
|
42
|
+
const claudeBin = resolveClaude(env.PATH);
|
|
43
|
+
this.proc = spawn(claudeBin, args, {
|
|
41
44
|
cwd: opts.cwd ?? process.cwd(),
|
|
42
45
|
env,
|
|
43
46
|
stdio: ["pipe", "pipe", "pipe"],
|
|
@@ -127,3 +130,29 @@ export function extractText(msg) {
|
|
|
127
130
|
}
|
|
128
131
|
return "";
|
|
129
132
|
}
|
|
133
|
+
/**
|
|
134
|
+
* Resolve the claude CLI binary path.
|
|
135
|
+
* Checks PATH entries + common npm global install locations.
|
|
136
|
+
*/
|
|
137
|
+
function resolveClaude(pathEnv) {
|
|
138
|
+
// Try PATH entries first
|
|
139
|
+
const dirs = (pathEnv ?? process.env.PATH ?? "").split(":");
|
|
140
|
+
for (const dir of dirs) {
|
|
141
|
+
const candidate = `${dir}/claude`;
|
|
142
|
+
if (existsSync(candidate))
|
|
143
|
+
return candidate;
|
|
144
|
+
}
|
|
145
|
+
// Common fallback locations
|
|
146
|
+
const fallbacks = [
|
|
147
|
+
`${process.env.HOME}/.npm-global/bin/claude`,
|
|
148
|
+
"/opt/homebrew/bin/claude",
|
|
149
|
+
"/usr/local/bin/claude",
|
|
150
|
+
"/usr/bin/claude",
|
|
151
|
+
];
|
|
152
|
+
for (const p of fallbacks) {
|
|
153
|
+
if (existsSync(p))
|
|
154
|
+
return p;
|
|
155
|
+
}
|
|
156
|
+
// Last resort — let the OS resolve it (will throw ENOENT if missing)
|
|
157
|
+
return "claude";
|
|
158
|
+
}
|
package/dist/voice.d.ts
ADDED
|
@@ -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,124 @@
|
|
|
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 } 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
|
+
const { stdout } = await execFileAsync(whisperBin, [
|
|
97
|
+
"-m", model,
|
|
98
|
+
"-f", wavPath,
|
|
99
|
+
"--no-timestamps",
|
|
100
|
+
"-l", "auto",
|
|
101
|
+
"--output-txt",
|
|
102
|
+
]);
|
|
103
|
+
// whisper outputs to stdout — strip leading/trailing whitespace and [BLANK_AUDIO] artifacts
|
|
104
|
+
const text = stdout
|
|
105
|
+
.replace(/\[BLANK_AUDIO\]/gi, "")
|
|
106
|
+
.replace(/\[.*?\]/g, "") // remove timestamp artifacts
|
|
107
|
+
.trim();
|
|
108
|
+
return text || "[empty transcription]";
|
|
109
|
+
}
|
|
110
|
+
finally {
|
|
111
|
+
// Cleanup temp files
|
|
112
|
+
await unlink(oggPath).catch(() => { });
|
|
113
|
+
await unlink(wavPath).catch(() => { });
|
|
114
|
+
await unlink(`${wavPath}.txt`).catch(() => { });
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Check if voice transcription is available on this system.
|
|
119
|
+
*/
|
|
120
|
+
export function isVoiceAvailable() {
|
|
121
|
+
return (findBin(WHISPER_BIN_CANDIDATES) !== null &&
|
|
122
|
+
findBin(FFMPEG_CANDIDATES) !== null &&
|
|
123
|
+
findModel() !== null);
|
|
124
|
+
}
|