@gonzih/cc-tg 0.2.1 → 0.2.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.
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,10 +12,12 @@ 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;
18
19
  private handleVoice;
20
+ private handlePhoto;
19
21
  private getOrCreateSession;
20
22
  private handleClaudeMessage;
21
23
  private startTyping;
@@ -24,6 +26,7 @@ export declare class CcTgBot {
24
26
  private trackWrittenFiles;
25
27
  private uploadMentionedFiles;
26
28
  private extractToolName;
29
+ private handleCron;
27
30
  private killSession;
28
31
  stop(): void;
29
32
  }
package/dist/bot.js CHANGED
@@ -5,19 +5,36 @@
5
5
  import TelegramBot from "node-telegram-bot-api";
6
6
  import { existsSync } from "fs";
7
7
  import { resolve, basename } from "path";
8
+ import https from "https";
9
+ import http from "http";
8
10
  import { ClaudeProcess, extractText } from "./claude.js";
9
11
  import { transcribeVoice, isVoiceAvailable } from "./voice.js";
12
+ import { CronManager } from "./cron.js";
10
13
  const FLUSH_DELAY_MS = 800; // debounce streaming chunks into one Telegram message
11
14
  const TYPING_INTERVAL_MS = 4000; // re-send typing action before Telegram's 5s expiry
12
15
  export class CcTgBot {
13
16
  bot;
14
17
  sessions = new Map();
15
18
  opts;
19
+ cron;
16
20
  constructor(opts) {
17
21
  this.opts = opts;
18
22
  this.bot = new TelegramBot(opts.telegramToken, { polling: true });
19
23
  this.bot.on("message", (msg) => this.handleTelegram(msg));
20
24
  this.bot.on("polling_error", (err) => console.error("[tg]", err.message));
25
+ // Cron manager — fires prompts into user sessions on schedule
26
+ this.cron = new CronManager(opts.cwd ?? process.cwd(), (chatId, prompt) => {
27
+ const session = this.getOrCreateSession(chatId);
28
+ try {
29
+ session.claude.sendPrompt(`[CRON: ${prompt}]\n\n${prompt}`);
30
+ this.startTyping(chatId, session);
31
+ // Tag result with cron prefix
32
+ session.pendingPrefix = `CRON: ${prompt}\n\n`;
33
+ }
34
+ catch (err) {
35
+ console.error(`[cron] failed to fire for chat=${chatId}:`, err.message);
36
+ }
37
+ });
21
38
  console.log("cc-tg bot started");
22
39
  console.log(`[voice] whisper available: ${isVoiceAvailable()}`);
23
40
  }
@@ -38,6 +55,11 @@ export class CcTgBot {
38
55
  await this.handleVoice(chatId, msg);
39
56
  return;
40
57
  }
58
+ // Photo — send as base64 image content block to Claude
59
+ if (msg.photo?.length) {
60
+ await this.handlePhoto(chatId, msg);
61
+ return;
62
+ }
41
63
  const text = msg.text?.trim();
42
64
  if (!text)
43
65
  return;
@@ -60,6 +82,11 @@ export class CcTgBot {
60
82
  await this.bot.sendMessage(chatId, has ? "Session active." : "No active session.");
61
83
  return;
62
84
  }
85
+ // /cron <schedule> <prompt> | /cron list | /cron clear | /cron remove <id>
86
+ if (text.startsWith("/cron")) {
87
+ await this.handleCron(chatId, text);
88
+ return;
89
+ }
63
90
  const session = this.getOrCreateSession(chatId);
64
91
  try {
65
92
  session.claude.sendPrompt(text);
@@ -100,6 +127,26 @@ export class CcTgBot {
100
127
  await this.bot.sendMessage(chatId, `Voice transcription failed: ${err.message}`);
101
128
  }
102
129
  }
130
+ async handlePhoto(chatId, msg) {
131
+ // Pick highest resolution photo
132
+ const photos = msg.photo;
133
+ const best = photos[photos.length - 1];
134
+ const caption = msg.caption?.trim();
135
+ console.log(`[photo:${chatId}] received image file_id=${best.file_id}`);
136
+ this.bot.sendChatAction(chatId, "typing").catch(() => { });
137
+ try {
138
+ const fileLink = await this.bot.getFileLink(best.file_id);
139
+ const imageData = await fetchAsBase64(fileLink);
140
+ // Telegram photos are always JPEG
141
+ const session = this.getOrCreateSession(chatId);
142
+ session.claude.sendImage(imageData, "image/jpeg", caption);
143
+ this.startTyping(chatId, session);
144
+ }
145
+ catch (err) {
146
+ console.error(`[photo:${chatId}] error:`, err.message);
147
+ await this.bot.sendMessage(chatId, `Failed to process image: ${err.message}`);
148
+ }
149
+ }
103
150
  getOrCreateSession(chatId) {
104
151
  const existing = this.sessions.get(chatId);
105
152
  if (existing && !existing.claude.exited)
@@ -111,6 +158,7 @@ export class CcTgBot {
111
158
  const session = {
112
159
  claude,
113
160
  pendingText: "",
161
+ pendingPrefix: "",
114
162
  flushTimer: null,
115
163
  typingTimer: null,
116
164
  writtenFiles: new Set(),
@@ -178,11 +226,14 @@ export class CcTgBot {
178
226
  }
179
227
  }
180
228
  flushPending(chatId, session) {
181
- const text = session.pendingText.trim();
229
+ const raw = session.pendingText.trim();
230
+ const prefix = session.pendingPrefix;
182
231
  session.pendingText = "";
232
+ session.pendingPrefix = "";
183
233
  session.flushTimer = null;
184
- if (!text)
234
+ if (!raw)
185
235
  return;
236
+ const text = prefix ? `${prefix}${raw}` : raw;
186
237
  // Telegram max message length is 4096 chars — split if needed
187
238
  const chunks = splitMessage(text);
188
239
  for (const chunk of chunks) {
@@ -274,13 +325,56 @@ export class CcTgBot {
274
325
  const toolUse = content.find((b) => b.type === "tool_use");
275
326
  return toolUse?.name ?? "";
276
327
  }
277
- killSession(chatId) {
328
+ async handleCron(chatId, text) {
329
+ const args = text.slice("/cron".length).trim();
330
+ // /cron list
331
+ if (args === "list" || args === "") {
332
+ const jobs = this.cron.list(chatId);
333
+ if (!jobs.length) {
334
+ await this.bot.sendMessage(chatId, "No cron jobs.");
335
+ return;
336
+ }
337
+ const lines = jobs.map((j) => `[${j.id}] ${j.schedule}: ${j.prompt}`);
338
+ await this.bot.sendMessage(chatId, lines.join("\n"));
339
+ return;
340
+ }
341
+ // /cron clear
342
+ if (args === "clear") {
343
+ const n = this.cron.clearAll(chatId);
344
+ await this.bot.sendMessage(chatId, `Cleared ${n} cron job(s).`);
345
+ return;
346
+ }
347
+ // /cron remove <id>
348
+ if (args.startsWith("remove ")) {
349
+ const id = args.slice("remove ".length).trim();
350
+ const ok = this.cron.remove(chatId, id);
351
+ await this.bot.sendMessage(chatId, ok ? `Removed ${id}.` : `Not found: ${id}`);
352
+ return;
353
+ }
354
+ // /cron every 1h <prompt>
355
+ const scheduleMatch = args.match(/^(every\s+\d+[mhd])\s+(.+)$/i);
356
+ if (!scheduleMatch) {
357
+ await this.bot.sendMessage(chatId, "Usage:\n/cron every 1h <prompt>\n/cron list\n/cron remove <id>\n/cron clear");
358
+ return;
359
+ }
360
+ const schedule = scheduleMatch[1];
361
+ const prompt = scheduleMatch[2];
362
+ const job = this.cron.add(chatId, schedule, prompt);
363
+ if (!job) {
364
+ await this.bot.sendMessage(chatId, "Invalid schedule. Use: every 30m / every 2h / every 1d");
365
+ return;
366
+ }
367
+ await this.bot.sendMessage(chatId, `Cron set [${job.id}]: ${schedule} — "${prompt}"`);
368
+ }
369
+ killSession(chatId, keepCrons = true) {
278
370
  const session = this.sessions.get(chatId);
279
371
  if (session) {
280
372
  this.stopTyping(session);
281
373
  session.claude.kill();
282
374
  this.sessions.delete(chatId);
283
375
  }
376
+ if (!keepCrons)
377
+ this.cron.clearAll(chatId);
284
378
  }
285
379
  stop() {
286
380
  this.bot.stopPolling();
@@ -289,6 +383,18 @@ export class CcTgBot {
289
383
  }
290
384
  }
291
385
  }
386
+ /** Download a URL and return its contents as a base64 string */
387
+ function fetchAsBase64(url) {
388
+ return new Promise((resolve, reject) => {
389
+ const client = url.startsWith("https") ? https : http;
390
+ client.get(url, (res) => {
391
+ const chunks = [];
392
+ res.on("data", (chunk) => chunks.push(chunk));
393
+ res.on("end", () => resolve(Buffer.concat(chunks).toString("base64")));
394
+ res.on("error", reject);
395
+ }).on("error", reject);
396
+ });
397
+ }
292
398
  function splitMessage(text, maxLen = 4096) {
293
399
  if (text.length <= maxLen)
294
400
  return [text];
package/dist/claude.d.ts CHANGED
@@ -30,6 +30,11 @@ export declare class ClaudeProcess extends EventEmitter {
30
30
  private _exited;
31
31
  constructor(opts?: ClaudeOptions);
32
32
  sendPrompt(text: string): void;
33
+ /**
34
+ * Send an image (with optional text caption) to Claude via stream-json content blocks.
35
+ * mediaType: image/jpeg | image/png | image/gif | image/webp
36
+ */
37
+ sendImage(base64Data: string, mediaType: string, caption?: string): void;
33
38
  kill(): void;
34
39
  get exited(): boolean;
35
40
  private drainBuffer;
package/dist/claude.js CHANGED
@@ -69,6 +69,31 @@ export class ClaudeProcess extends EventEmitter {
69
69
  });
70
70
  this.proc.stdin.write(payload + "\n");
71
71
  }
72
+ /**
73
+ * Send an image (with optional text caption) to Claude via stream-json content blocks.
74
+ * mediaType: image/jpeg | image/png | image/gif | image/webp
75
+ */
76
+ sendImage(base64Data, mediaType, caption) {
77
+ if (this._exited)
78
+ throw new Error("Claude process has exited");
79
+ const content = [];
80
+ if (caption) {
81
+ content.push({ type: "text", text: caption });
82
+ }
83
+ content.push({
84
+ type: "image",
85
+ source: {
86
+ type: "base64",
87
+ media_type: mediaType,
88
+ data: base64Data,
89
+ },
90
+ });
91
+ const payload = JSON.stringify({
92
+ type: "user",
93
+ message: { role: "user", content },
94
+ });
95
+ this.proc.stdin.write(payload + "\n");
96
+ }
72
97
  kill() {
73
98
  this.proc.kill();
74
99
  }
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.3",
4
4
  "description": "Claude Code Telegram bot — chat with Claude Code via Telegram",
5
5
  "type": "module",
6
6
  "bin": {