@gonzih/cc-tg 0.9.5 → 0.9.6
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 +67 -3
- package/dist/cron.d.ts +39 -0
- package/dist/cron.js +148 -0
- package/dist/notifier.js +6 -2
- package/dist/usage-limit.js +2 -3
- package/package.json +3 -3
package/dist/bot.d.ts
CHANGED
|
@@ -24,6 +24,7 @@ export declare class CcTgBot {
|
|
|
24
24
|
private redis?;
|
|
25
25
|
private namespace;
|
|
26
26
|
private lastActiveChatId?;
|
|
27
|
+
private cron;
|
|
27
28
|
constructor(opts: BotOptions);
|
|
28
29
|
private registerBotCommands;
|
|
29
30
|
/** Write a message to the Redis chat log. Fire-and-forget — no-op if Redis is not configured. */
|
|
@@ -68,6 +69,8 @@ export declare class CcTgBot {
|
|
|
68
69
|
private handleMcpVersion;
|
|
69
70
|
private handleClearNpxCache;
|
|
70
71
|
private handleRestart;
|
|
72
|
+
private handleCron;
|
|
73
|
+
private runCronTask;
|
|
71
74
|
private handleGetFile;
|
|
72
75
|
private callCcAgentTool;
|
|
73
76
|
private killSession;
|
package/dist/bot.js
CHANGED
|
@@ -15,6 +15,7 @@ import { formatForTelegram, splitLongMessage } from "./formatter.js";
|
|
|
15
15
|
import { detectUsageLimit } from "./usage-limit.js";
|
|
16
16
|
import { getCurrentToken, rotateToken, getTokenIndex, getTokenCount } from "./tokens.js";
|
|
17
17
|
import { writeChatLog } from "./notifier.js";
|
|
18
|
+
import { CronManager } from "./cron.js";
|
|
18
19
|
const BOT_COMMANDS = [
|
|
19
20
|
{ command: "start", description: "Reset session and start fresh" },
|
|
20
21
|
{ command: "reset", description: "Reset Claude session" },
|
|
@@ -29,6 +30,7 @@ const BOT_COMMANDS = [
|
|
|
29
30
|
{ command: "get_file", description: "Send a file from the server to this chat" },
|
|
30
31
|
{ command: "cost", description: "Show session token usage and cost" },
|
|
31
32
|
{ command: "skills", description: "List available Claude skills with descriptions" },
|
|
33
|
+
{ command: "cron", description: "Manage cron jobs — add/list/edit/remove/clear" },
|
|
32
34
|
];
|
|
33
35
|
const FLUSH_DELAY_MS = 800; // debounce streaming chunks into one Telegram message
|
|
34
36
|
const TYPING_INTERVAL_MS = 4000; // re-send typing action before Telegram's 5s expiry
|
|
@@ -157,6 +159,7 @@ export class CcTgBot {
|
|
|
157
159
|
redis;
|
|
158
160
|
namespace;
|
|
159
161
|
lastActiveChatId;
|
|
162
|
+
cron;
|
|
160
163
|
constructor(opts) {
|
|
161
164
|
this.opts = opts;
|
|
162
165
|
this.redis = opts.redis;
|
|
@@ -170,6 +173,9 @@ export class CcTgBot {
|
|
|
170
173
|
console.log(`[tg] bot identity: @${this.botUsername} (id=${this.botId})`);
|
|
171
174
|
}).catch((err) => console.error("[tg] getMe failed:", err.message));
|
|
172
175
|
this.costStore = new CostStore(opts.cwd ?? process.cwd());
|
|
176
|
+
this.cron = new CronManager(opts.cwd ?? process.cwd(), (chatId, prompt, _jobId, done) => {
|
|
177
|
+
this.runCronTask(chatId, prompt, done);
|
|
178
|
+
});
|
|
173
179
|
this.registerBotCommands();
|
|
174
180
|
console.log("cc-tg bot started");
|
|
175
181
|
console.log(`[voice] whisper available: ${isVoiceAvailable()}`);
|
|
@@ -343,6 +349,11 @@ export class CcTgBot {
|
|
|
343
349
|
await this.handleRestart(chatId, threadId);
|
|
344
350
|
return;
|
|
345
351
|
}
|
|
352
|
+
// /cron <schedule> <prompt> | /cron list | /cron clear | /cron remove <id>
|
|
353
|
+
if (text.startsWith("/cron")) {
|
|
354
|
+
await this.handleCron(chatId, text, threadId);
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
346
357
|
// /get_file <path> — send a file from the server to the user
|
|
347
358
|
if (text.startsWith("/get_file")) {
|
|
348
359
|
await this.handleGetFile(chatId, text, threadId);
|
|
@@ -415,8 +426,6 @@ export class CcTgBot {
|
|
|
415
426
|
await this.replyToChat(chatId, "Could not transcribe voice message.", threadId);
|
|
416
427
|
return;
|
|
417
428
|
}
|
|
418
|
-
// Log transcript to chat bridge so it appears in cc-agent-ui
|
|
419
|
-
this.writeChatMessage("user", "telegram", `🎤 ${transcript}`, chatId);
|
|
420
429
|
// Feed transcript into Claude as if user typed it
|
|
421
430
|
const session = this.getOrCreateSession(chatId, threadId, threadName);
|
|
422
431
|
try {
|
|
@@ -535,7 +544,7 @@ export class CcTgBot {
|
|
|
535
544
|
continue;
|
|
536
545
|
const name = block.name;
|
|
537
546
|
const input = block.input;
|
|
538
|
-
this.writeChatMessage("tool", "cc-tg", `[tool] ${name}: ${JSON.stringify(input ?? {})}`, chatId);
|
|
547
|
+
this.writeChatMessage("tool", "cc-tg", `[tool] ${name}: ${JSON.stringify(input ?? {}).slice(0, 120)}`, chatId);
|
|
539
548
|
}
|
|
540
549
|
}
|
|
541
550
|
}
|
|
@@ -973,6 +982,61 @@ export class CcTgBot {
|
|
|
973
982
|
await new Promise(resolve => setTimeout(resolve, 200));
|
|
974
983
|
process.exit(0);
|
|
975
984
|
}
|
|
985
|
+
async handleCron(chatId, text, threadId) {
|
|
986
|
+
const args = text.slice("/cron".length).trim();
|
|
987
|
+
if (args === "list" || args === "") {
|
|
988
|
+
const jobs = this.cron.list(chatId);
|
|
989
|
+
if (!jobs.length) {
|
|
990
|
+
await this.replyToChat(chatId, "No cron jobs.", threadId);
|
|
991
|
+
return;
|
|
992
|
+
}
|
|
993
|
+
const lines = jobs.map((j, i) => {
|
|
994
|
+
const short = j.prompt.length > 50 ? j.prompt.slice(0, 50) + "…" : j.prompt;
|
|
995
|
+
return `#${i + 1} ${j.schedule} — "${short}"`;
|
|
996
|
+
});
|
|
997
|
+
await this.replyToChat(chatId, `Cron jobs (${jobs.length}):\n${lines.join("\n")}`, threadId);
|
|
998
|
+
return;
|
|
999
|
+
}
|
|
1000
|
+
if (args === "clear") {
|
|
1001
|
+
const n = this.cron.clearAll(chatId);
|
|
1002
|
+
await this.replyToChat(chatId, `Cleared ${n} cron job(s).`, threadId);
|
|
1003
|
+
return;
|
|
1004
|
+
}
|
|
1005
|
+
if (args.startsWith("remove ")) {
|
|
1006
|
+
const id = args.slice("remove ".length).trim();
|
|
1007
|
+
const ok = this.cron.remove(chatId, id);
|
|
1008
|
+
await this.replyToChat(chatId, ok ? `Removed ${id}.` : `Not found: ${id}`, threadId);
|
|
1009
|
+
return;
|
|
1010
|
+
}
|
|
1011
|
+
const scheduleMatch = args.match(/^(every\s+\d+[mhd])\s+(.+)$/i);
|
|
1012
|
+
if (!scheduleMatch) {
|
|
1013
|
+
await this.replyToChat(chatId, "Usage:\n/cron every 1h <prompt>\n/cron list\n/cron remove <id>\n/cron clear", threadId);
|
|
1014
|
+
return;
|
|
1015
|
+
}
|
|
1016
|
+
const schedule = scheduleMatch[1];
|
|
1017
|
+
const prompt = scheduleMatch[2];
|
|
1018
|
+
const job = this.cron.add(chatId, schedule, prompt);
|
|
1019
|
+
if (!job) {
|
|
1020
|
+
await this.replyToChat(chatId, "Invalid schedule. Use: every 30m / every 2h / every 1d", threadId);
|
|
1021
|
+
return;
|
|
1022
|
+
}
|
|
1023
|
+
await this.replyToChat(chatId, `Cron set [${job.id}]: ${schedule} — "${prompt}"`, threadId);
|
|
1024
|
+
}
|
|
1025
|
+
runCronTask(chatId, prompt, done = () => { }) {
|
|
1026
|
+
const cronProcess = new ClaudeProcess({ cwd: this.opts.cwd ?? process.cwd() });
|
|
1027
|
+
cronProcess.sendPrompt(prompt);
|
|
1028
|
+
cronProcess.on("message", (msg) => {
|
|
1029
|
+
const result = extractText(msg);
|
|
1030
|
+
if (result) {
|
|
1031
|
+
const formatted = formatForTelegram(`🕐 ${result}`);
|
|
1032
|
+
const chunks = splitLongMessage(formatted);
|
|
1033
|
+
for (const chunk of chunks) {
|
|
1034
|
+
this.replyToChat(chatId, chunk).catch((err) => console.error("[cron] send failed:", err.message));
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
});
|
|
1038
|
+
cronProcess.on("exit", () => done());
|
|
1039
|
+
}
|
|
976
1040
|
async handleGetFile(chatId, text, threadId) {
|
|
977
1041
|
const arg = text.slice("/get_file".length).trim();
|
|
978
1042
|
if (!arg) {
|
package/dist/cron.d.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
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
|
+
/** Called when a job fires. `done` must be called when the task completes so
|
|
15
|
+
* the next scheduled tick is allowed to run. Until `done` is called, concurrent
|
|
16
|
+
* ticks for the same job are silently skipped (prevents the resume-loop explosion
|
|
17
|
+
* where each tick spawns more agents than the last). */
|
|
18
|
+
type FireCallback = (chatId: number, prompt: string, jobId: string, done: () => void) => void;
|
|
19
|
+
export declare class CronManager {
|
|
20
|
+
private jobs;
|
|
21
|
+
/** Job IDs whose fire callback has been invoked but whose `done` hasn't fired yet. */
|
|
22
|
+
private activeJobs;
|
|
23
|
+
private storePath;
|
|
24
|
+
private fire;
|
|
25
|
+
constructor(cwd: string, fire: FireCallback);
|
|
26
|
+
/** Parse "every 30m", "every 2h", "every 1d" → ms */
|
|
27
|
+
static parseSchedule(schedule: string): number | null;
|
|
28
|
+
add(chatId: number, schedule: string, prompt: string): CronJob | null;
|
|
29
|
+
remove(chatId: number, id: string): boolean;
|
|
30
|
+
clearAll(chatId: number): number;
|
|
31
|
+
list(chatId: number): CronJob[];
|
|
32
|
+
update(chatId: number, id: string, updates: {
|
|
33
|
+
schedule?: string;
|
|
34
|
+
prompt?: string;
|
|
35
|
+
}): CronJob | null | false;
|
|
36
|
+
private persist;
|
|
37
|
+
private load;
|
|
38
|
+
}
|
|
39
|
+
export {};
|
package/dist/cron.js
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
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
|
+
/** Job IDs whose fire callback has been invoked but whose `done` hasn't fired yet. */
|
|
11
|
+
activeJobs = new Set();
|
|
12
|
+
storePath;
|
|
13
|
+
fire;
|
|
14
|
+
constructor(cwd, fire) {
|
|
15
|
+
this.storePath = join(cwd, ".cc-tg", "crons.json");
|
|
16
|
+
this.fire = fire;
|
|
17
|
+
this.load();
|
|
18
|
+
}
|
|
19
|
+
/** Parse "every 30m", "every 2h", "every 1d" → ms */
|
|
20
|
+
static parseSchedule(schedule) {
|
|
21
|
+
const m = schedule.trim().match(/^every\s+(\d+)(m|h|d)$/i);
|
|
22
|
+
if (!m)
|
|
23
|
+
return null;
|
|
24
|
+
const n = parseInt(m[1]);
|
|
25
|
+
const unit = m[2].toLowerCase();
|
|
26
|
+
if (unit === "m")
|
|
27
|
+
return n * 60_000;
|
|
28
|
+
if (unit === "h")
|
|
29
|
+
return n * 3_600_000;
|
|
30
|
+
if (unit === "d")
|
|
31
|
+
return n * 86_400_000;
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
add(chatId, schedule, prompt) {
|
|
35
|
+
const intervalMs = CronManager.parseSchedule(schedule);
|
|
36
|
+
if (!intervalMs)
|
|
37
|
+
return null;
|
|
38
|
+
const id = `${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
|
|
39
|
+
const job = { id, chatId, intervalMs, prompt, schedule, createdAt: new Date().toISOString() };
|
|
40
|
+
const timer = setInterval(() => {
|
|
41
|
+
if (this.activeJobs.has(id)) {
|
|
42
|
+
console.log(`[cron:${id}] skipping tick — previous task still running`);
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
this.activeJobs.add(id);
|
|
46
|
+
console.log(`[cron:${id}] firing for chat=${chatId} prompt="${prompt}"`);
|
|
47
|
+
this.fire(chatId, prompt, id, () => { this.activeJobs.delete(id); });
|
|
48
|
+
}, intervalMs);
|
|
49
|
+
this.jobs.set(id, { ...job, timer });
|
|
50
|
+
this.persist();
|
|
51
|
+
return job;
|
|
52
|
+
}
|
|
53
|
+
remove(chatId, id) {
|
|
54
|
+
const job = this.jobs.get(id);
|
|
55
|
+
if (!job || job.chatId !== chatId)
|
|
56
|
+
return false;
|
|
57
|
+
clearInterval(job.timer);
|
|
58
|
+
this.activeJobs.delete(id);
|
|
59
|
+
this.jobs.delete(id);
|
|
60
|
+
this.persist();
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
clearAll(chatId) {
|
|
64
|
+
let count = 0;
|
|
65
|
+
for (const [id, job] of this.jobs) {
|
|
66
|
+
if (job.chatId === chatId) {
|
|
67
|
+
clearInterval(job.timer);
|
|
68
|
+
this.activeJobs.delete(id);
|
|
69
|
+
this.jobs.delete(id);
|
|
70
|
+
count++;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
if (count)
|
|
74
|
+
this.persist();
|
|
75
|
+
return count;
|
|
76
|
+
}
|
|
77
|
+
list(chatId) {
|
|
78
|
+
return [...this.jobs.values()]
|
|
79
|
+
.filter((j) => j.chatId === chatId)
|
|
80
|
+
.map(({ timer: _t, ...j }) => j);
|
|
81
|
+
}
|
|
82
|
+
update(chatId, id, updates) {
|
|
83
|
+
const job = this.jobs.get(id);
|
|
84
|
+
if (!job || job.chatId !== chatId)
|
|
85
|
+
return false;
|
|
86
|
+
if (updates.schedule !== undefined) {
|
|
87
|
+
const intervalMs = CronManager.parseSchedule(updates.schedule);
|
|
88
|
+
if (!intervalMs)
|
|
89
|
+
return null;
|
|
90
|
+
job.intervalMs = intervalMs;
|
|
91
|
+
job.schedule = updates.schedule;
|
|
92
|
+
}
|
|
93
|
+
if (updates.prompt !== undefined) {
|
|
94
|
+
job.prompt = updates.prompt;
|
|
95
|
+
}
|
|
96
|
+
// Recreate timer so it uses updated intervalMs and always reads latest job.prompt
|
|
97
|
+
clearInterval(job.timer);
|
|
98
|
+
// Also clear any active-job lock so the updated timer can fire immediately next tick
|
|
99
|
+
this.activeJobs.delete(job.id);
|
|
100
|
+
job.timer = setInterval(() => {
|
|
101
|
+
if (this.activeJobs.has(job.id)) {
|
|
102
|
+
console.log(`[cron:${job.id}] skipping tick — previous task still running`);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
this.activeJobs.add(job.id);
|
|
106
|
+
console.log(`[cron:${job.id}] firing for chat=${job.chatId} prompt="${job.prompt}"`);
|
|
107
|
+
this.fire(job.chatId, job.prompt, job.id, () => { this.activeJobs.delete(job.id); });
|
|
108
|
+
}, job.intervalMs);
|
|
109
|
+
this.persist();
|
|
110
|
+
const { timer: _t, ...cronJob } = job;
|
|
111
|
+
return cronJob;
|
|
112
|
+
}
|
|
113
|
+
persist() {
|
|
114
|
+
try {
|
|
115
|
+
const dir = join(this.storePath, "..");
|
|
116
|
+
if (!existsSync(dir))
|
|
117
|
+
mkdirSync(dir, { recursive: true });
|
|
118
|
+
const data = [...this.jobs.values()].map(({ timer: _t, ...j }) => j);
|
|
119
|
+
writeFileSync(this.storePath, JSON.stringify(data, null, 2));
|
|
120
|
+
}
|
|
121
|
+
catch (err) {
|
|
122
|
+
console.error("[cron] persist error:", err.message);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
load() {
|
|
126
|
+
if (!existsSync(this.storePath))
|
|
127
|
+
return;
|
|
128
|
+
try {
|
|
129
|
+
const data = JSON.parse(readFileSync(this.storePath, "utf8"));
|
|
130
|
+
for (const job of data) {
|
|
131
|
+
const timer = setInterval(() => {
|
|
132
|
+
if (this.activeJobs.has(job.id)) {
|
|
133
|
+
console.log(`[cron:${job.id}] skipping tick — previous task still running`);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
this.activeJobs.add(job.id);
|
|
137
|
+
console.log(`[cron:${job.id}] firing for chat=${job.chatId} prompt="${job.prompt}"`);
|
|
138
|
+
this.fire(job.chatId, job.prompt, job.id, () => { this.activeJobs.delete(job.id); });
|
|
139
|
+
}, job.intervalMs);
|
|
140
|
+
this.jobs.set(job.id, { ...job, timer });
|
|
141
|
+
}
|
|
142
|
+
console.log(`[cron] loaded ${data.length} jobs from disk`);
|
|
143
|
+
}
|
|
144
|
+
catch (err) {
|
|
145
|
+
console.error("[cron] load error:", err.message);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
package/dist/notifier.js
CHANGED
|
@@ -77,11 +77,15 @@ export function startNotifier(bot, chatId, namespace, redis, handleUserMessage,
|
|
|
77
77
|
const notifyChannel = `cca:notify:${namespace}`;
|
|
78
78
|
const incomingChannel = `cca:chat:incoming:${namespace}`;
|
|
79
79
|
if (channel === notifyChannel) {
|
|
80
|
-
|
|
81
|
-
|
|
80
|
+
const targetId = chatId ?? getActiveChatId?.();
|
|
81
|
+
if (targetId != null) {
|
|
82
|
+
bot.sendMessage(targetId, message).catch((err) => {
|
|
82
83
|
log("warn", "sendMessage failed:", err.message);
|
|
83
84
|
});
|
|
84
85
|
}
|
|
86
|
+
else {
|
|
87
|
+
log("warn", "notify: no chatId available, dropping notification");
|
|
88
|
+
}
|
|
85
89
|
return;
|
|
86
90
|
}
|
|
87
91
|
if (channel === incomingChannel) {
|
package/dist/usage-limit.js
CHANGED
|
@@ -3,8 +3,7 @@ export function detectUsageLimit(text) {
|
|
|
3
3
|
if (lower.includes('extra usage') ||
|
|
4
4
|
lower.includes('usage has been disabled') ||
|
|
5
5
|
lower.includes('billing_error') ||
|
|
6
|
-
lower.includes('usage limit
|
|
7
|
-
lower.includes('your usage limit')) {
|
|
6
|
+
lower.includes('usage limit')) {
|
|
8
7
|
const wake = nextHourBoundary() + 5 * 60 * 1000;
|
|
9
8
|
return {
|
|
10
9
|
detected: true,
|
|
@@ -13,7 +12,7 @@ export function detectUsageLimit(text) {
|
|
|
13
12
|
humanMessage: `⏸ Claude usage limit reached. Will auto-resume at ${new Date(wake).toUTCString()}. I'll message you when it's back.`,
|
|
14
13
|
};
|
|
15
14
|
}
|
|
16
|
-
if (lower.includes('
|
|
15
|
+
if (lower.includes('rate limit') || lower.includes('overloaded')) {
|
|
17
16
|
return {
|
|
18
17
|
detected: true,
|
|
19
18
|
reason: 'rate_limit',
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gonzih/cc-tg",
|
|
3
|
-
"version": "0.9.
|
|
4
|
-
"description": "Claude Code Telegram bot
|
|
3
|
+
"version": "0.9.6",
|
|
4
|
+
"description": "Claude Code Telegram bot — chat with Claude Code via Telegram",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"cc-tg": "./dist/index.js"
|
|
@@ -44,4 +44,4 @@
|
|
|
44
44
|
"ai"
|
|
45
45
|
],
|
|
46
46
|
"license": "MIT"
|
|
47
|
-
}
|
|
47
|
+
}
|