@gonzih/cc-tg 0.1.9 → 0.2.2
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/README.md +98 -15
- package/dist/bot.d.ts +5 -0
- package/dist/bot.js +187 -6
- package/dist/cron.d.ts +29 -0
- package/dist/cron.js +103 -0
- package/dist/voice.d.ts +13 -0
- package/dist/voice.js +124 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# cc-tg
|
|
2
2
|
|
|
3
|
-
Claude Code Telegram bot. Chat with Claude Code from Telegram.
|
|
3
|
+
Claude Code Telegram bot. Chat with Claude Code from Telegram — voice messages, scheduled prompts, and automatic file delivery.
|
|
4
4
|
|
|
5
5
|
## Quickstart
|
|
6
6
|
|
|
@@ -19,12 +19,12 @@ That's it. Open your bot in Telegram and start chatting.
|
|
|
19
19
|
| Variable | Required | Description |
|
|
20
20
|
|---|---|---|
|
|
21
21
|
| `TELEGRAM_BOT_TOKEN` | yes | From @BotFather |
|
|
22
|
-
| `CLAUDE_CODE_TOKEN` | yes* | Claude Code OAuth token |
|
|
23
|
-
| `ANTHROPIC_API_KEY` | yes* | Alternative
|
|
22
|
+
| `CLAUDE_CODE_TOKEN` | yes* | Claude Code OAuth token (starts with `sk-ant-oat`) |
|
|
23
|
+
| `ANTHROPIC_API_KEY` | yes* | Alternative — API key from console.anthropic.com |
|
|
24
24
|
| `ALLOWED_USER_IDS` | no | Comma-separated Telegram user IDs. Leave empty to allow anyone |
|
|
25
25
|
| `CWD` | no | Working directory for Claude Code. Defaults to current directory |
|
|
26
26
|
|
|
27
|
-
*One of CLAUDE_CODE_TOKEN or ANTHROPIC_API_KEY is required.
|
|
27
|
+
*One of `CLAUDE_CODE_TOKEN` or `ANTHROPIC_API_KEY` is required.
|
|
28
28
|
|
|
29
29
|
## How to get your Claude Code token
|
|
30
30
|
|
|
@@ -36,27 +36,107 @@ npx @anthropic-ai/claude-code setup-token
|
|
|
36
36
|
|
|
37
37
|
It opens a browser, logs you in with your Anthropic account, and prints a token starting with `sk-ant-oat`. Paste that as `CLAUDE_CODE_TOKEN`.
|
|
38
38
|
|
|
39
|
-
Alternatively, use an `ANTHROPIC_API_KEY` from [console.anthropic.com](https://console.anthropic.com) (API key starts with `sk-ant-api`).
|
|
40
|
-
|
|
41
39
|
## How to get your Telegram user ID
|
|
42
40
|
|
|
43
|
-
Message [@userinfobot](https://t.me/userinfobot) on Telegram — it replies with your ID.
|
|
41
|
+
Message [@userinfobot](https://t.me/userinfobot) on Telegram — it replies with your numeric ID.
|
|
44
42
|
|
|
45
43
|
## Bot commands
|
|
46
44
|
|
|
47
45
|
| Command | Action |
|
|
48
46
|
|---|---|
|
|
49
|
-
| `/start` |
|
|
50
|
-
| `/
|
|
51
|
-
| `/
|
|
52
|
-
| `/
|
|
47
|
+
| `/start` or `/reset` | Kill current Claude session and start fresh |
|
|
48
|
+
| `/stop` | Interrupt the running Claude task |
|
|
49
|
+
| `/status` | Check if a session is active |
|
|
50
|
+
| `/cron every 1h <prompt>` | Schedule a recurring prompt |
|
|
51
|
+
| `/cron list` | Show active cron jobs |
|
|
52
|
+
| `/cron remove <id>` | Remove a specific cron job |
|
|
53
|
+
| `/cron clear` | Remove all cron jobs |
|
|
53
54
|
| Any text | Sent directly to Claude Code |
|
|
55
|
+
| Voice message | Transcribed via whisper.cpp and sent to Claude |
|
|
56
|
+
|
|
57
|
+
## Features
|
|
58
|
+
|
|
59
|
+
### Persistent sessions
|
|
60
|
+
Each Telegram chat ID gets its own isolated Claude Code subprocess. Sessions survive between messages — Claude remembers context within a conversation. `/reset` starts a fresh session.
|
|
61
|
+
|
|
62
|
+
### Voice messages
|
|
63
|
+
Send a voice message → automatically transcribed via whisper.cpp → fed into Claude as text. Requires `whisper-cpp` and `ffmpeg` installed on the host.
|
|
64
|
+
|
|
65
|
+
### File delivery
|
|
66
|
+
When Claude writes a file and mentions it in the response, the bot automatically uploads it to Telegram. Hybrid detection: tracks `Write`/`Edit` tool calls during the session, cross-references with filenames mentioned in the final response.
|
|
67
|
+
|
|
68
|
+
### Cron jobs
|
|
69
|
+
Schedule recurring prompts that fire into your Claude session on a timer:
|
|
70
|
+
|
|
71
|
+
```
|
|
72
|
+
/cron every 1h check whale-watcher logs and summarize any new large trades
|
|
73
|
+
/cron every 6h run the market scan and save results to daily-report.md
|
|
74
|
+
/cron every 30m ping the API and alert me if anything looks off
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Cron jobs persist to `<CWD>/.cc-tg/crons.json` and are restored on bot restart. Output is prefixed with `CRON: <prompt>` so you know what triggered it. Files written by cron jobs are uploaded the same way as regular responses.
|
|
78
|
+
|
|
79
|
+
### Typing indicator
|
|
80
|
+
While Claude is working, the bot sends a continuous typing indicator so you know it's active.
|
|
81
|
+
|
|
82
|
+
### Permissions
|
|
83
|
+
Runs Claude Code with `--dangerously-skip-permissions` — no confirmation prompts blocking headless execution.
|
|
54
84
|
|
|
55
85
|
## How it works
|
|
56
86
|
|
|
57
|
-
Spawns a `claude` CLI subprocess per chat session using the
|
|
87
|
+
Spawns a `claude` CLI subprocess per chat session using the stream-JSON protocol (same mechanism as the [ce_ce](https://github.com/ityonemo/ce_ce) Elixir library). Prompts pipe in via stdin, streaming JSON responses parse out. Only the final `result` message is forwarded to Telegram — no duplicate streaming chunks.
|
|
88
|
+
|
|
89
|
+
## Run persistently
|
|
90
|
+
|
|
91
|
+
### macOS launchd
|
|
92
|
+
|
|
93
|
+
```xml
|
|
94
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
95
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
96
|
+
<plist version="1.0">
|
|
97
|
+
<dict>
|
|
98
|
+
<key>Label</key>
|
|
99
|
+
<string>com.yourname.cc-tg</string>
|
|
100
|
+
<key>ProgramArguments</key>
|
|
101
|
+
<array>
|
|
102
|
+
<string>/opt/homebrew/bin/npx</string>
|
|
103
|
+
<string>-y</string>
|
|
104
|
+
<string>@gonzih/cc-tg</string>
|
|
105
|
+
</array>
|
|
106
|
+
<key>EnvironmentVariables</key>
|
|
107
|
+
<dict>
|
|
108
|
+
<key>TELEGRAM_BOT_TOKEN</key>
|
|
109
|
+
<string>your_token</string>
|
|
110
|
+
<key>CLAUDE_CODE_TOKEN</key>
|
|
111
|
+
<string>your_claude_token</string>
|
|
112
|
+
<key>ALLOWED_USER_IDS</key>
|
|
113
|
+
<string>your_telegram_id</string>
|
|
114
|
+
<key>CWD</key>
|
|
115
|
+
<string>/Users/you/your-project</string>
|
|
116
|
+
<key>PATH</key>
|
|
117
|
+
<string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
|
|
118
|
+
</dict>
|
|
119
|
+
<key>WorkingDirectory</key>
|
|
120
|
+
<string>/Users/you/your-project</string>
|
|
121
|
+
<key>RunAtLoad</key>
|
|
122
|
+
<true/>
|
|
123
|
+
<key>KeepAlive</key>
|
|
124
|
+
<true/>
|
|
125
|
+
<key>StandardOutPath</key>
|
|
126
|
+
<string>/tmp/cc-tg.log</string>
|
|
127
|
+
<key>StandardErrorPath</key>
|
|
128
|
+
<string>/tmp/cc-tg.log</string>
|
|
129
|
+
</dict>
|
|
130
|
+
</plist>
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
Save to `~/Library/LaunchAgents/com.yourname.cc-tg.plist`, then:
|
|
134
|
+
|
|
135
|
+
```bash
|
|
136
|
+
launchctl load ~/Library/LaunchAgents/com.yourname.cc-tg.plist
|
|
137
|
+
```
|
|
58
138
|
|
|
59
|
-
|
|
139
|
+
### Linux systemd
|
|
60
140
|
|
|
61
141
|
```ini
|
|
62
142
|
[Unit]
|
|
@@ -66,7 +146,9 @@ Description=cc-tg Claude Code Telegram bot
|
|
|
66
146
|
Environment=TELEGRAM_BOT_TOKEN=xxx
|
|
67
147
|
Environment=CLAUDE_CODE_TOKEN=yyy
|
|
68
148
|
Environment=ALLOWED_USER_IDS=123456789
|
|
69
|
-
|
|
149
|
+
Environment=CWD=/home/you/your-project
|
|
150
|
+
WorkingDirectory=/home/you/your-project
|
|
151
|
+
ExecStart=npx -y @gonzih/cc-tg
|
|
70
152
|
Restart=always
|
|
71
153
|
|
|
72
154
|
[Install]
|
|
@@ -76,4 +158,5 @@ WantedBy=multi-user.target
|
|
|
76
158
|
## Requirements
|
|
77
159
|
|
|
78
160
|
- Node.js 18+
|
|
79
|
-
- `claude` CLI
|
|
161
|
+
- `claude` CLI: `npm install -g @anthropic-ai/claude-code`
|
|
162
|
+
- Voice transcription (optional): `whisper-cpp` + `ffmpeg`
|
package/dist/bot.d.ts
CHANGED
|
@@ -12,15 +12,20 @@ export declare class CcTgBot {
|
|
|
12
12
|
private bot;
|
|
13
13
|
private sessions;
|
|
14
14
|
private opts;
|
|
15
|
+
private cron;
|
|
15
16
|
constructor(opts: BotOptions);
|
|
16
17
|
private isAllowed;
|
|
17
18
|
private handleTelegram;
|
|
19
|
+
private handleVoice;
|
|
18
20
|
private getOrCreateSession;
|
|
19
21
|
private handleClaudeMessage;
|
|
20
22
|
private startTyping;
|
|
21
23
|
private stopTyping;
|
|
22
24
|
private flushPending;
|
|
25
|
+
private trackWrittenFiles;
|
|
26
|
+
private uploadMentionedFiles;
|
|
23
27
|
private extractToolName;
|
|
28
|
+
private handleCron;
|
|
24
29
|
private killSession;
|
|
25
30
|
stop(): void;
|
|
26
31
|
}
|
package/dist/bot.js
CHANGED
|
@@ -3,19 +3,38 @@
|
|
|
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";
|
|
10
|
+
import { CronManager } from "./cron.js";
|
|
7
11
|
const FLUSH_DELAY_MS = 800; // debounce streaming chunks into one Telegram message
|
|
8
12
|
const TYPING_INTERVAL_MS = 4000; // re-send typing action before Telegram's 5s expiry
|
|
9
13
|
export class CcTgBot {
|
|
10
14
|
bot;
|
|
11
15
|
sessions = new Map();
|
|
12
16
|
opts;
|
|
17
|
+
cron;
|
|
13
18
|
constructor(opts) {
|
|
14
19
|
this.opts = opts;
|
|
15
20
|
this.bot = new TelegramBot(opts.telegramToken, { polling: true });
|
|
16
21
|
this.bot.on("message", (msg) => this.handleTelegram(msg));
|
|
17
22
|
this.bot.on("polling_error", (err) => console.error("[tg]", err.message));
|
|
23
|
+
// Cron manager — fires prompts into user sessions on schedule
|
|
24
|
+
this.cron = new CronManager(opts.cwd ?? process.cwd(), (chatId, prompt) => {
|
|
25
|
+
const session = this.getOrCreateSession(chatId);
|
|
26
|
+
try {
|
|
27
|
+
session.claude.sendPrompt(`[CRON: ${prompt}]\n\n${prompt}`);
|
|
28
|
+
this.startTyping(chatId, session);
|
|
29
|
+
// Tag result with cron prefix
|
|
30
|
+
session.pendingPrefix = `CRON: ${prompt}\n\n`;
|
|
31
|
+
}
|
|
32
|
+
catch (err) {
|
|
33
|
+
console.error(`[cron] failed to fire for chat=${chatId}:`, err.message);
|
|
34
|
+
}
|
|
35
|
+
});
|
|
18
36
|
console.log("cc-tg bot started");
|
|
37
|
+
console.log(`[voice] whisper available: ${isVoiceAvailable()}`);
|
|
19
38
|
}
|
|
20
39
|
isAllowed(userId) {
|
|
21
40
|
if (!this.opts.allowedUserIds?.length)
|
|
@@ -25,13 +44,18 @@ export class CcTgBot {
|
|
|
25
44
|
async handleTelegram(msg) {
|
|
26
45
|
const chatId = msg.chat.id;
|
|
27
46
|
const userId = msg.from?.id ?? chatId;
|
|
28
|
-
const text = msg.text?.trim();
|
|
29
|
-
if (!text)
|
|
30
|
-
return;
|
|
31
47
|
if (!this.isAllowed(userId)) {
|
|
32
48
|
await this.bot.sendMessage(chatId, "Not authorized.");
|
|
33
49
|
return;
|
|
34
50
|
}
|
|
51
|
+
// Voice message — transcribe then feed as text
|
|
52
|
+
if (msg.voice || msg.audio) {
|
|
53
|
+
await this.handleVoice(chatId, msg);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
const text = msg.text?.trim();
|
|
57
|
+
if (!text)
|
|
58
|
+
return;
|
|
35
59
|
// /start or /reset — kill existing session and ack
|
|
36
60
|
if (text === "/start" || text === "/reset") {
|
|
37
61
|
this.killSession(chatId);
|
|
@@ -51,6 +75,11 @@ export class CcTgBot {
|
|
|
51
75
|
await this.bot.sendMessage(chatId, has ? "Session active." : "No active session.");
|
|
52
76
|
return;
|
|
53
77
|
}
|
|
78
|
+
// /cron <schedule> <prompt> | /cron list | /cron clear | /cron remove <id>
|
|
79
|
+
if (text.startsWith("/cron")) {
|
|
80
|
+
await this.handleCron(chatId, text);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
54
83
|
const session = this.getOrCreateSession(chatId);
|
|
55
84
|
try {
|
|
56
85
|
session.claude.sendPrompt(text);
|
|
@@ -61,6 +90,36 @@ export class CcTgBot {
|
|
|
61
90
|
this.killSession(chatId);
|
|
62
91
|
}
|
|
63
92
|
}
|
|
93
|
+
async handleVoice(chatId, msg) {
|
|
94
|
+
const fileId = msg.voice?.file_id ?? msg.audio?.file_id;
|
|
95
|
+
if (!fileId)
|
|
96
|
+
return;
|
|
97
|
+
console.log(`[voice:${chatId}] received voice message, transcribing...`);
|
|
98
|
+
this.bot.sendChatAction(chatId, "typing").catch(() => { });
|
|
99
|
+
try {
|
|
100
|
+
const fileLink = await this.bot.getFileLink(fileId);
|
|
101
|
+
const transcript = await transcribeVoice(fileLink);
|
|
102
|
+
console.log(`[voice:${chatId}] transcribed: ${transcript}`);
|
|
103
|
+
if (!transcript || transcript === "[empty transcription]") {
|
|
104
|
+
await this.bot.sendMessage(chatId, "Could not transcribe voice message.");
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
// Feed transcript into Claude as if user typed it
|
|
108
|
+
const session = this.getOrCreateSession(chatId);
|
|
109
|
+
try {
|
|
110
|
+
session.claude.sendPrompt(transcript);
|
|
111
|
+
this.startTyping(chatId, session);
|
|
112
|
+
}
|
|
113
|
+
catch (err) {
|
|
114
|
+
await this.bot.sendMessage(chatId, `Error sending to Claude: ${err.message}`);
|
|
115
|
+
this.killSession(chatId);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
catch (err) {
|
|
119
|
+
console.error(`[voice:${chatId}] error:`, err.message);
|
|
120
|
+
await this.bot.sendMessage(chatId, `Voice transcription failed: ${err.message}`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
64
123
|
getOrCreateSession(chatId) {
|
|
65
124
|
const existing = this.sessions.get(chatId);
|
|
66
125
|
if (existing && !existing.claude.exited)
|
|
@@ -72,8 +131,10 @@ export class CcTgBot {
|
|
|
72
131
|
const session = {
|
|
73
132
|
claude,
|
|
74
133
|
pendingText: "",
|
|
134
|
+
pendingPrefix: "",
|
|
75
135
|
flushTimer: null,
|
|
76
136
|
typingTimer: null,
|
|
137
|
+
writtenFiles: new Set(),
|
|
77
138
|
};
|
|
78
139
|
claude.on("message", (msg) => {
|
|
79
140
|
// Verbose logging — log every message type and subtype
|
|
@@ -85,6 +146,8 @@ export class CcTgBot {
|
|
|
85
146
|
if (toolName)
|
|
86
147
|
logParts.push(`tool=${toolName}`);
|
|
87
148
|
console.log(logParts.join(" "));
|
|
149
|
+
// Track files written by Write/Edit tool calls
|
|
150
|
+
this.trackWrittenFiles(msg, session, this.opts.cwd);
|
|
88
151
|
this.handleClaudeMessage(chatId, session, msg);
|
|
89
152
|
});
|
|
90
153
|
claude.on("stderr", (data) => {
|
|
@@ -136,11 +199,14 @@ export class CcTgBot {
|
|
|
136
199
|
}
|
|
137
200
|
}
|
|
138
201
|
flushPending(chatId, session) {
|
|
139
|
-
const
|
|
202
|
+
const raw = session.pendingText.trim();
|
|
203
|
+
const prefix = session.pendingPrefix;
|
|
140
204
|
session.pendingText = "";
|
|
205
|
+
session.pendingPrefix = "";
|
|
141
206
|
session.flushTimer = null;
|
|
142
|
-
if (!
|
|
207
|
+
if (!raw)
|
|
143
208
|
return;
|
|
209
|
+
const text = prefix ? `${prefix}${raw}` : raw;
|
|
144
210
|
// Telegram max message length is 4096 chars — split if needed
|
|
145
211
|
const chunks = splitMessage(text);
|
|
146
212
|
for (const chunk of chunks) {
|
|
@@ -149,6 +215,78 @@ export class CcTgBot {
|
|
|
149
215
|
this.bot.sendMessage(chatId, chunk).catch((err) => console.error(`[tg:${chatId}] send failed:`, err.message));
|
|
150
216
|
});
|
|
151
217
|
}
|
|
218
|
+
// Hybrid file upload: find files mentioned in result text that Claude actually wrote
|
|
219
|
+
this.uploadMentionedFiles(chatId, text, session);
|
|
220
|
+
}
|
|
221
|
+
trackWrittenFiles(msg, session, cwd) {
|
|
222
|
+
// Only look at assistant messages with tool_use blocks
|
|
223
|
+
if (msg.type !== "assistant")
|
|
224
|
+
return;
|
|
225
|
+
const message = msg.payload.message;
|
|
226
|
+
if (!message)
|
|
227
|
+
return;
|
|
228
|
+
const content = message.content;
|
|
229
|
+
if (!Array.isArray(content))
|
|
230
|
+
return;
|
|
231
|
+
for (const block of content) {
|
|
232
|
+
if (block.type !== "tool_use")
|
|
233
|
+
continue;
|
|
234
|
+
const name = block.name;
|
|
235
|
+
if (!["Write", "Edit", "NotebookEdit"].includes(name))
|
|
236
|
+
continue;
|
|
237
|
+
const input = block.input;
|
|
238
|
+
if (!input)
|
|
239
|
+
continue;
|
|
240
|
+
// Write tool uses file_path, Edit uses file_path
|
|
241
|
+
const filePath = input.file_path ?? input.path;
|
|
242
|
+
if (!filePath)
|
|
243
|
+
continue;
|
|
244
|
+
// Resolve relative paths against cwd
|
|
245
|
+
const resolved = filePath.startsWith("/")
|
|
246
|
+
? filePath
|
|
247
|
+
: resolve(cwd ?? process.cwd(), filePath);
|
|
248
|
+
console.log(`[claude:files] tracked written file: ${resolved}`);
|
|
249
|
+
session.writtenFiles.add(resolved);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
uploadMentionedFiles(chatId, resultText, session) {
|
|
253
|
+
if (session.writtenFiles.size === 0)
|
|
254
|
+
return;
|
|
255
|
+
// Extract file path candidates from result text
|
|
256
|
+
// Match: /absolute/path/file.ext or relative like ./foo/bar.csv or just foo.pdf
|
|
257
|
+
const pathPattern = /(?:^|[\s`'"(])(\/?[\w.\-/]+\.[\w]{1,10})(?:[\s`'")\n]|$)/gm;
|
|
258
|
+
const candidates = new Set();
|
|
259
|
+
let match;
|
|
260
|
+
while ((match = pathPattern.exec(resultText)) !== null) {
|
|
261
|
+
candidates.add(match[1]);
|
|
262
|
+
}
|
|
263
|
+
const toUpload = [];
|
|
264
|
+
for (const candidate of candidates) {
|
|
265
|
+
// Try as-is (absolute), or resolve against cwd
|
|
266
|
+
const resolved = candidate.startsWith("/")
|
|
267
|
+
? candidate
|
|
268
|
+
: resolve(this.opts.cwd ?? process.cwd(), candidate);
|
|
269
|
+
if (session.writtenFiles.has(resolved) && existsSync(resolved)) {
|
|
270
|
+
toUpload.push(resolved);
|
|
271
|
+
}
|
|
272
|
+
else {
|
|
273
|
+
// Also check by basename — result might mention just the filename
|
|
274
|
+
for (const written of session.writtenFiles) {
|
|
275
|
+
if (basename(written) === basename(candidate) && existsSync(written)) {
|
|
276
|
+
toUpload.push(written);
|
|
277
|
+
break;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
// Deduplicate
|
|
283
|
+
const unique = [...new Set(toUpload)];
|
|
284
|
+
for (const filePath of unique) {
|
|
285
|
+
console.log(`[claude:files] uploading to telegram: ${filePath}`);
|
|
286
|
+
this.bot.sendDocument(chatId, filePath).catch((err) => console.error(`[tg:${chatId}] sendDocument failed for ${filePath}:`, err.message));
|
|
287
|
+
}
|
|
288
|
+
// Clear written files for next turn
|
|
289
|
+
session.writtenFiles.clear();
|
|
152
290
|
}
|
|
153
291
|
extractToolName(msg) {
|
|
154
292
|
const message = msg.payload.message;
|
|
@@ -160,13 +298,56 @@ export class CcTgBot {
|
|
|
160
298
|
const toolUse = content.find((b) => b.type === "tool_use");
|
|
161
299
|
return toolUse?.name ?? "";
|
|
162
300
|
}
|
|
163
|
-
|
|
301
|
+
async handleCron(chatId, text) {
|
|
302
|
+
const args = text.slice("/cron".length).trim();
|
|
303
|
+
// /cron list
|
|
304
|
+
if (args === "list" || args === "") {
|
|
305
|
+
const jobs = this.cron.list(chatId);
|
|
306
|
+
if (!jobs.length) {
|
|
307
|
+
await this.bot.sendMessage(chatId, "No cron jobs.");
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
const lines = jobs.map((j) => `[${j.id}] ${j.schedule}: ${j.prompt}`);
|
|
311
|
+
await this.bot.sendMessage(chatId, lines.join("\n"));
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
// /cron clear
|
|
315
|
+
if (args === "clear") {
|
|
316
|
+
const n = this.cron.clearAll(chatId);
|
|
317
|
+
await this.bot.sendMessage(chatId, `Cleared ${n} cron job(s).`);
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
// /cron remove <id>
|
|
321
|
+
if (args.startsWith("remove ")) {
|
|
322
|
+
const id = args.slice("remove ".length).trim();
|
|
323
|
+
const ok = this.cron.remove(chatId, id);
|
|
324
|
+
await this.bot.sendMessage(chatId, ok ? `Removed ${id}.` : `Not found: ${id}`);
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
// /cron every 1h <prompt>
|
|
328
|
+
const scheduleMatch = args.match(/^(every\s+\d+[mhd])\s+(.+)$/i);
|
|
329
|
+
if (!scheduleMatch) {
|
|
330
|
+
await this.bot.sendMessage(chatId, "Usage:\n/cron every 1h <prompt>\n/cron list\n/cron remove <id>\n/cron clear");
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
const schedule = scheduleMatch[1];
|
|
334
|
+
const prompt = scheduleMatch[2];
|
|
335
|
+
const job = this.cron.add(chatId, schedule, prompt);
|
|
336
|
+
if (!job) {
|
|
337
|
+
await this.bot.sendMessage(chatId, "Invalid schedule. Use: every 30m / every 2h / every 1d");
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
await this.bot.sendMessage(chatId, `Cron set [${job.id}]: ${schedule} — "${prompt}"`);
|
|
341
|
+
}
|
|
342
|
+
killSession(chatId, keepCrons = true) {
|
|
164
343
|
const session = this.sessions.get(chatId);
|
|
165
344
|
if (session) {
|
|
166
345
|
this.stopTyping(session);
|
|
167
346
|
session.claude.kill();
|
|
168
347
|
this.sessions.delete(chatId);
|
|
169
348
|
}
|
|
349
|
+
if (!keepCrons)
|
|
350
|
+
this.cron.clearAll(chatId);
|
|
170
351
|
}
|
|
171
352
|
stop() {
|
|
172
353
|
this.bot.stopPolling();
|
package/dist/cron.d.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cron job manager for cc-tg.
|
|
3
|
+
* Persists jobs to <cwd>/.cc-tg/crons.json.
|
|
4
|
+
* Fires prompts into Claude sessions on schedule.
|
|
5
|
+
*/
|
|
6
|
+
export interface CronJob {
|
|
7
|
+
id: string;
|
|
8
|
+
chatId: number;
|
|
9
|
+
intervalMs: number;
|
|
10
|
+
prompt: string;
|
|
11
|
+
createdAt: string;
|
|
12
|
+
schedule: string;
|
|
13
|
+
}
|
|
14
|
+
type FireCallback = (chatId: number, prompt: string) => void;
|
|
15
|
+
export declare class CronManager {
|
|
16
|
+
private jobs;
|
|
17
|
+
private storePath;
|
|
18
|
+
private fire;
|
|
19
|
+
constructor(cwd: string, fire: FireCallback);
|
|
20
|
+
/** Parse "every 30m", "every 2h", "every 1d" → ms */
|
|
21
|
+
static parseSchedule(schedule: string): number | null;
|
|
22
|
+
add(chatId: number, schedule: string, prompt: string): CronJob | null;
|
|
23
|
+
remove(chatId: number, id: string): boolean;
|
|
24
|
+
clearAll(chatId: number): number;
|
|
25
|
+
list(chatId: number): CronJob[];
|
|
26
|
+
private persist;
|
|
27
|
+
private load;
|
|
28
|
+
}
|
|
29
|
+
export {};
|
package/dist/cron.js
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cron job manager for cc-tg.
|
|
3
|
+
* Persists jobs to <cwd>/.cc-tg/crons.json.
|
|
4
|
+
* Fires prompts into Claude sessions on schedule.
|
|
5
|
+
*/
|
|
6
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs";
|
|
7
|
+
import { join } from "path";
|
|
8
|
+
export class CronManager {
|
|
9
|
+
jobs = new Map();
|
|
10
|
+
storePath;
|
|
11
|
+
fire;
|
|
12
|
+
constructor(cwd, fire) {
|
|
13
|
+
this.storePath = join(cwd, ".cc-tg", "crons.json");
|
|
14
|
+
this.fire = fire;
|
|
15
|
+
this.load();
|
|
16
|
+
}
|
|
17
|
+
/** Parse "every 30m", "every 2h", "every 1d" → ms */
|
|
18
|
+
static parseSchedule(schedule) {
|
|
19
|
+
const m = schedule.trim().match(/^every\s+(\d+)(m|h|d)$/i);
|
|
20
|
+
if (!m)
|
|
21
|
+
return null;
|
|
22
|
+
const n = parseInt(m[1]);
|
|
23
|
+
const unit = m[2].toLowerCase();
|
|
24
|
+
if (unit === "m")
|
|
25
|
+
return n * 60_000;
|
|
26
|
+
if (unit === "h")
|
|
27
|
+
return n * 3_600_000;
|
|
28
|
+
if (unit === "d")
|
|
29
|
+
return n * 86_400_000;
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
add(chatId, schedule, prompt) {
|
|
33
|
+
const intervalMs = CronManager.parseSchedule(schedule);
|
|
34
|
+
if (!intervalMs)
|
|
35
|
+
return null;
|
|
36
|
+
const id = `${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
|
|
37
|
+
const job = { id, chatId, intervalMs, prompt, schedule, createdAt: new Date().toISOString() };
|
|
38
|
+
const timer = setInterval(() => {
|
|
39
|
+
console.log(`[cron:${id}] firing for chat=${chatId} prompt="${prompt}"`);
|
|
40
|
+
this.fire(chatId, prompt);
|
|
41
|
+
}, intervalMs);
|
|
42
|
+
this.jobs.set(id, { ...job, timer });
|
|
43
|
+
this.persist();
|
|
44
|
+
return job;
|
|
45
|
+
}
|
|
46
|
+
remove(chatId, id) {
|
|
47
|
+
const job = this.jobs.get(id);
|
|
48
|
+
if (!job || job.chatId !== chatId)
|
|
49
|
+
return false;
|
|
50
|
+
clearInterval(job.timer);
|
|
51
|
+
this.jobs.delete(id);
|
|
52
|
+
this.persist();
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
clearAll(chatId) {
|
|
56
|
+
let count = 0;
|
|
57
|
+
for (const [id, job] of this.jobs) {
|
|
58
|
+
if (job.chatId === chatId) {
|
|
59
|
+
clearInterval(job.timer);
|
|
60
|
+
this.jobs.delete(id);
|
|
61
|
+
count++;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
if (count)
|
|
65
|
+
this.persist();
|
|
66
|
+
return count;
|
|
67
|
+
}
|
|
68
|
+
list(chatId) {
|
|
69
|
+
return [...this.jobs.values()]
|
|
70
|
+
.filter((j) => j.chatId === chatId)
|
|
71
|
+
.map(({ timer: _t, ...j }) => j);
|
|
72
|
+
}
|
|
73
|
+
persist() {
|
|
74
|
+
try {
|
|
75
|
+
const dir = join(this.storePath, "..");
|
|
76
|
+
if (!existsSync(dir))
|
|
77
|
+
mkdirSync(dir, { recursive: true });
|
|
78
|
+
const data = [...this.jobs.values()].map(({ timer: _t, ...j }) => j);
|
|
79
|
+
writeFileSync(this.storePath, JSON.stringify(data, null, 2));
|
|
80
|
+
}
|
|
81
|
+
catch (err) {
|
|
82
|
+
console.error("[cron] persist error:", err.message);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
load() {
|
|
86
|
+
if (!existsSync(this.storePath))
|
|
87
|
+
return;
|
|
88
|
+
try {
|
|
89
|
+
const data = JSON.parse(readFileSync(this.storePath, "utf8"));
|
|
90
|
+
for (const job of data) {
|
|
91
|
+
const timer = setInterval(() => {
|
|
92
|
+
console.log(`[cron:${job.id}] firing for chat=${job.chatId} prompt="${job.prompt}"`);
|
|
93
|
+
this.fire(job.chatId, job.prompt);
|
|
94
|
+
}, job.intervalMs);
|
|
95
|
+
this.jobs.set(job.id, { ...job, timer });
|
|
96
|
+
}
|
|
97
|
+
console.log(`[cron] loaded ${data.length} jobs from disk`);
|
|
98
|
+
}
|
|
99
|
+
catch (err) {
|
|
100
|
+
console.error("[cron] load error:", err.message);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
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
|
+
}
|