@gonzih/cc-tg 0.1.9 → 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 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;
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gonzih/cc-tg",
3
- "version": "0.1.9",
3
+ "version": "0.2.1",
4
4
  "description": "Claude Code Telegram bot — chat with Claude Code via Telegram",
5
5
  "type": "module",
6
6
  "bin": {