@gonzih/cc-tg 0.2.1 → 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 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 to CLAUDE_CODE_TOKEN |
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` | Reset session |
50
- | `/reset` | Reset session |
51
- | `/stop` | Interrupt current Claude task |
52
- | `/status` | Check if session is active |
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 same stream-JSON protocol as the [ce_ce](https://github.com/ityonemo/ce_ce) Elixir library. Each Telegram chat gets its own isolated Claude Code session. Messages stream back in real time, debounced into Telegram messages.
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
- ## Run persistently (systemd)
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
- ExecStart=npx @gonzih/cc-tg
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 installed and in PATH (`npm install -g @anthropic-ai/claude-code`)
161
+ - `claude` CLI: `npm install -g @anthropic-ai/claude-code`
162
+ - Voice transcription (optional): `whisper-cpp` + `ffmpeg`
package/dist/bot.d.ts CHANGED
@@ -12,6 +12,7 @@ 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;
@@ -24,6 +25,7 @@ export declare class CcTgBot {
24
25
  private trackWrittenFiles;
25
26
  private uploadMentionedFiles;
26
27
  private extractToolName;
28
+ private handleCron;
27
29
  private killSession;
28
30
  stop(): void;
29
31
  }
package/dist/bot.js CHANGED
@@ -7,17 +7,32 @@ import { existsSync } from "fs";
7
7
  import { resolve, basename } from "path";
8
8
  import { ClaudeProcess, extractText } from "./claude.js";
9
9
  import { transcribeVoice, isVoiceAvailable } from "./voice.js";
10
+ import { CronManager } from "./cron.js";
10
11
  const FLUSH_DELAY_MS = 800; // debounce streaming chunks into one Telegram message
11
12
  const TYPING_INTERVAL_MS = 4000; // re-send typing action before Telegram's 5s expiry
12
13
  export class CcTgBot {
13
14
  bot;
14
15
  sessions = new Map();
15
16
  opts;
17
+ cron;
16
18
  constructor(opts) {
17
19
  this.opts = opts;
18
20
  this.bot = new TelegramBot(opts.telegramToken, { polling: true });
19
21
  this.bot.on("message", (msg) => this.handleTelegram(msg));
20
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
+ });
21
36
  console.log("cc-tg bot started");
22
37
  console.log(`[voice] whisper available: ${isVoiceAvailable()}`);
23
38
  }
@@ -60,6 +75,11 @@ export class CcTgBot {
60
75
  await this.bot.sendMessage(chatId, has ? "Session active." : "No active session.");
61
76
  return;
62
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
+ }
63
83
  const session = this.getOrCreateSession(chatId);
64
84
  try {
65
85
  session.claude.sendPrompt(text);
@@ -111,6 +131,7 @@ export class CcTgBot {
111
131
  const session = {
112
132
  claude,
113
133
  pendingText: "",
134
+ pendingPrefix: "",
114
135
  flushTimer: null,
115
136
  typingTimer: null,
116
137
  writtenFiles: new Set(),
@@ -178,11 +199,14 @@ export class CcTgBot {
178
199
  }
179
200
  }
180
201
  flushPending(chatId, session) {
181
- const text = session.pendingText.trim();
202
+ const raw = session.pendingText.trim();
203
+ const prefix = session.pendingPrefix;
182
204
  session.pendingText = "";
205
+ session.pendingPrefix = "";
183
206
  session.flushTimer = null;
184
- if (!text)
207
+ if (!raw)
185
208
  return;
209
+ const text = prefix ? `${prefix}${raw}` : raw;
186
210
  // Telegram max message length is 4096 chars — split if needed
187
211
  const chunks = splitMessage(text);
188
212
  for (const chunk of chunks) {
@@ -274,13 +298,56 @@ export class CcTgBot {
274
298
  const toolUse = content.find((b) => b.type === "tool_use");
275
299
  return toolUse?.name ?? "";
276
300
  }
277
- killSession(chatId) {
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) {
278
343
  const session = this.sessions.get(chatId);
279
344
  if (session) {
280
345
  this.stopTyping(session);
281
346
  session.claude.kill();
282
347
  this.sessions.delete(chatId);
283
348
  }
349
+ if (!keepCrons)
350
+ this.cron.clearAll(chatId);
284
351
  }
285
352
  stop() {
286
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gonzih/cc-tg",
3
- "version": "0.2.1",
3
+ "version": "0.2.2",
4
4
  "description": "Claude Code Telegram bot — chat with Claude Code via Telegram",
5
5
  "type": "module",
6
6
  "bin": {