@gonzih/cc-tg 0.3.13 → 0.3.15

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
@@ -2,6 +2,7 @@
2
2
  * Telegram bot that routes messages to/from a Claude Code subprocess.
3
3
  * One ClaudeProcess per chat_id — sessions are isolated per user.
4
4
  */
5
+ import TelegramBot from "node-telegram-bot-api";
5
6
  export interface BotOptions {
6
7
  telegramToken: string;
7
8
  claudeToken?: string;
@@ -49,6 +50,7 @@ export declare class CcTgBot {
49
50
  private handleGetFile;
50
51
  private callCcAgentTool;
51
52
  private killSession;
53
+ getMe(): Promise<TelegramBot.User>;
52
54
  stop(): void;
53
55
  }
54
56
  export declare function splitMessage(text: string, maxLen?: number): string[];
package/dist/bot.js CHANGED
@@ -168,9 +168,11 @@ export class CcTgBot {
168
168
  this.botId = me.id;
169
169
  console.log(`[tg] bot identity: @${this.botUsername} (id=${this.botId})`);
170
170
  }).catch((err) => console.error("[tg] getMe failed:", err.message));
171
- // Cron manager — fires each task into an isolated ClaudeProcess
172
- this.cron = new CronManager(opts.cwd ?? process.cwd(), (chatId, prompt) => {
173
- this.runCronTask(chatId, prompt);
171
+ // Cron manager — fires each task into an isolated ClaudeProcess.
172
+ // The `done` callback is passed through to runCronTask so the cron manager
173
+ // knows when a task finishes and can allow the next tick to run.
174
+ this.cron = new CronManager(opts.cwd ?? process.cwd(), (chatId, prompt, jobId, done) => {
175
+ this.runCronTask(chatId, prompt, done);
174
176
  });
175
177
  this.costStore = new CostStore(opts.cwd ?? process.cwd());
176
178
  this.registerBotCommands();
@@ -524,12 +526,12 @@ export class CcTgBot {
524
526
  return;
525
527
  const text = session.isRetry ? `✅ Claude is back!\n\n${raw}` : raw;
526
528
  session.isRetry = false;
527
- // Format for Telegram MarkdownV2 and split if needed (max 4096 chars)
529
+ // Format for Telegram HTML and split if needed (max 4096 chars)
528
530
  const formatted = formatForTelegram(text);
529
531
  const chunks = splitLongMessage(formatted);
530
532
  for (const chunk of chunks) {
531
- this.bot.sendMessage(chatId, chunk, { parse_mode: "MarkdownV2" }).catch(() => {
532
- // MarkdownV2 parse failed — retry as plain text
533
+ this.bot.sendMessage(chatId, chunk, { parse_mode: "HTML" }).catch(() => {
534
+ // HTML parse failed — retry as plain text
533
535
  this.bot.sendMessage(chatId, chunk).catch((err) => console.error(`[tg:${chatId}] send failed:`, err.message));
534
536
  });
535
537
  }
@@ -722,7 +724,7 @@ export class CcTgBot {
722
724
  const toolUse = content.find((b) => b.type === "tool_use");
723
725
  return toolUse?.name ?? "";
724
726
  }
725
- runCronTask(chatId, prompt) {
727
+ runCronTask(chatId, prompt, done = () => { }) {
726
728
  // Fresh isolated Claude session — never touches main conversation
727
729
  const cronProcess = new ClaudeProcess({
728
730
  cwd: this.opts.cwd,
@@ -763,10 +765,10 @@ export class CcTgBot {
763
765
  (async () => {
764
766
  for (const chunk of chunks) {
765
767
  try {
766
- await this.bot.sendMessage(chatId, chunk, { parse_mode: "MarkdownV2" });
768
+ await this.bot.sendMessage(chatId, chunk, { parse_mode: "HTML" });
767
769
  }
768
770
  catch {
769
- // MarkdownV2 parse failed — retry as plain text
771
+ // HTML parse failed — retry as plain text
770
772
  try {
771
773
  await this.bot.sendMessage(chatId, chunk);
772
774
  }
@@ -783,9 +785,11 @@ export class CcTgBot {
783
785
  cronProcess.on("error", (err) => {
784
786
  console.error(`[cron] task error for chat=${chatId}:`, err.message);
785
787
  cronProcess.kill();
788
+ done();
786
789
  });
787
790
  cronProcess.on("exit", () => {
788
791
  console.log(`[cron] task complete for chat=${chatId}`);
792
+ done();
789
793
  });
790
794
  cronProcess.sendPrompt(taskPrompt);
791
795
  }
@@ -1147,6 +1151,9 @@ export class CcTgBot {
1147
1151
  if (!keepCrons)
1148
1152
  this.cron.clearAll(chatId);
1149
1153
  }
1154
+ getMe() {
1155
+ return this.bot.getMe();
1156
+ }
1150
1157
  stop() {
1151
1158
  this.bot.stopPolling();
1152
1159
  for (const [chatId] of this.sessions) {
package/dist/cron.d.ts CHANGED
@@ -11,9 +11,15 @@ export interface CronJob {
11
11
  createdAt: string;
12
12
  schedule: string;
13
13
  }
14
- type FireCallback = (chatId: number, prompt: string) => void;
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;
15
19
  export declare class CronManager {
16
20
  private jobs;
21
+ /** Job IDs whose fire callback has been invoked but whose `done` hasn't fired yet. */
22
+ private activeJobs;
17
23
  private storePath;
18
24
  private fire;
19
25
  constructor(cwd: string, fire: FireCallback);
package/dist/cron.js CHANGED
@@ -7,6 +7,8 @@ import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs";
7
7
  import { join } from "path";
8
8
  export class CronManager {
9
9
  jobs = new Map();
10
+ /** Job IDs whose fire callback has been invoked but whose `done` hasn't fired yet. */
11
+ activeJobs = new Set();
10
12
  storePath;
11
13
  fire;
12
14
  constructor(cwd, fire) {
@@ -36,8 +38,13 @@ export class CronManager {
36
38
  const id = `${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
37
39
  const job = { id, chatId, intervalMs, prompt, schedule, createdAt: new Date().toISOString() };
38
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);
39
46
  console.log(`[cron:${id}] firing for chat=${chatId} prompt="${prompt}"`);
40
- this.fire(chatId, prompt);
47
+ this.fire(chatId, prompt, id, () => { this.activeJobs.delete(id); });
41
48
  }, intervalMs);
42
49
  this.jobs.set(id, { ...job, timer });
43
50
  this.persist();
@@ -48,6 +55,7 @@ export class CronManager {
48
55
  if (!job || job.chatId !== chatId)
49
56
  return false;
50
57
  clearInterval(job.timer);
58
+ this.activeJobs.delete(id);
51
59
  this.jobs.delete(id);
52
60
  this.persist();
53
61
  return true;
@@ -57,6 +65,7 @@ export class CronManager {
57
65
  for (const [id, job] of this.jobs) {
58
66
  if (job.chatId === chatId) {
59
67
  clearInterval(job.timer);
68
+ this.activeJobs.delete(id);
60
69
  this.jobs.delete(id);
61
70
  count++;
62
71
  }
@@ -86,9 +95,16 @@ export class CronManager {
86
95
  }
87
96
  // Recreate timer so it uses updated intervalMs and always reads latest job.prompt
88
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);
89
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);
90
106
  console.log(`[cron:${job.id}] firing for chat=${job.chatId} prompt="${job.prompt}"`);
91
- this.fire(job.chatId, job.prompt);
107
+ this.fire(job.chatId, job.prompt, job.id, () => { this.activeJobs.delete(job.id); });
92
108
  }, job.intervalMs);
93
109
  this.persist();
94
110
  const { timer: _t, ...cronJob } = job;
@@ -113,8 +129,13 @@ export class CronManager {
113
129
  const data = JSON.parse(readFileSync(this.storePath, "utf8"));
114
130
  for (const job of data) {
115
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);
116
137
  console.log(`[cron:${job.id}] firing for chat=${job.chatId} prompt="${job.prompt}"`);
117
- this.fire(job.chatId, job.prompt);
138
+ this.fire(job.chatId, job.prompt, job.id, () => { this.activeJobs.delete(job.id); });
118
139
  }, job.intervalMs);
119
140
  this.jobs.set(job.id, { ...job, timer });
120
141
  }
@@ -1,23 +1,25 @@
1
1
  /**
2
- * Telegram MarkdownV2 post-processor.
3
- * Converts standard markdown to Telegram's MarkdownV2 format.
2
+ * Telegram HTML post-processor.
3
+ * Converts standard markdown to Telegram's HTML parse mode format.
4
4
  */
5
5
  /**
6
- * Convert standard markdown text to Telegram MarkdownV2 format.
6
+ * Convert standard markdown text to Telegram HTML format.
7
7
  *
8
8
  * Processing order:
9
- * 1. Extract code blocks (fenced + inline) protect from further processing
10
- * 2. Strip raw HTML tags
11
- * 3. Convert ---blank line
12
- * 4. Convert ## headings *bold*
13
- * 5. Convert **bold***bold*
14
- * 6. Convert - list items • item
15
- * 7. Escape MarkdownV2 special chars (outside code blocks)
16
- * 8. Reinsert code blocks unchanged
9
+ * 1. Extract fenced code blocks (``` ... ```) <pre>, protect from further processing
10
+ * 2. Extract inline code (`...`) → <code>, protect from further processing
11
+ * 3. HTML-escape remaining text: & &amp; < → &lt; > → &gt;
12
+ * 4. Convert ---blank line
13
+ * 5. Convert ## headings <b>Heading</b>
14
+ * 6. Convert **bold**<b>bold</b>
15
+ * 7. Convert - item / * item → • item
16
+ * 8. Convert *bold* <b>bold</b>
17
+ * 9. Convert _italic_ → <i>italic</i>
18
+ * 10. Reinsert code blocks
17
19
  */
18
20
  export declare function formatForTelegram(text: string): string;
19
21
  /**
20
22
  * Split a long message at natural boundaries (paragraph > line > word).
21
- * Never splits mid-word. Chunks are at most maxLen characters.
23
+ * Never splits mid-word or inside <pre> blocks. Chunks are at most maxLen characters.
22
24
  */
23
25
  export declare function splitLongMessage(text: string, maxLen?: number): string[];
package/dist/formatter.js CHANGED
@@ -1,54 +1,82 @@
1
1
  /**
2
- * Telegram MarkdownV2 post-processor.
3
- * Converts standard markdown to Telegram's MarkdownV2 format.
2
+ * Telegram HTML post-processor.
3
+ * Converts standard markdown to Telegram's HTML parse mode format.
4
4
  */
5
+ function htmlEscape(text) {
6
+ return text
7
+ .replace(/&/g, "&amp;")
8
+ .replace(/</g, "&lt;")
9
+ .replace(/>/g, "&gt;");
10
+ }
5
11
  /**
6
- * Convert standard markdown text to Telegram MarkdownV2 format.
12
+ * Convert standard markdown text to Telegram HTML format.
7
13
  *
8
14
  * Processing order:
9
- * 1. Extract code blocks (fenced + inline) protect from further processing
10
- * 2. Strip raw HTML tags
11
- * 3. Convert ---blank line
12
- * 4. Convert ## headings *bold*
13
- * 5. Convert **bold***bold*
14
- * 6. Convert - list items • item
15
- * 7. Escape MarkdownV2 special chars (outside code blocks)
16
- * 8. Reinsert code blocks unchanged
15
+ * 1. Extract fenced code blocks (``` ... ```) <pre>, protect from further processing
16
+ * 2. Extract inline code (`...`) → <code>, protect from further processing
17
+ * 3. HTML-escape remaining text: & &amp; < → &lt; > → &gt;
18
+ * 4. Convert ---blank line
19
+ * 5. Convert ## headings <b>Heading</b>
20
+ * 6. Convert **bold**<b>bold</b>
21
+ * 7. Convert - item / * item → • item
22
+ * 8. Convert *bold* <b>bold</b>
23
+ * 9. Convert _italic_ → <i>italic</i>
24
+ * 10. Reinsert code blocks
17
25
  */
18
26
  export function formatForTelegram(text) {
19
- // Step 1: Extract code blocks and inline code to protect them
20
27
  const placeholders = [];
21
- // Fenced code blocks first (``` ... ```)
22
- let out = text.replace(/```[\s\S]*?```/g, (match) => {
23
- placeholders.push(match);
28
+ // Step 1: Extract fenced code blocks (``` ... ```) → <pre>
29
+ let out = text.replace(/```(?:\w*)\n?([\s\S]*?)```/g, (_, content) => {
30
+ placeholders.push(`<pre>${htmlEscape(content)}</pre>`);
24
31
  return `\x00P${placeholders.length - 1}\x00`;
25
32
  });
26
- // Inline code (`...`)
27
- out = out.replace(/`[^`\n]+`/g, (match) => {
28
- placeholders.push(match);
33
+ // Step 2: Extract inline code (`...`) → <code>
34
+ out = out.replace(/`([^`\n]+)`/g, (_, content) => {
35
+ placeholders.push(`<code>${htmlEscape(content)}</code>`);
29
36
  return `\x00P${placeholders.length - 1}\x00`;
30
37
  });
31
- // Step 2: Strip raw HTML tags
32
- out = out.replace(/<[^>]+>/g, "");
33
- // Step 3: Convert --- → blank line
38
+ // Step 3: HTML-escape remaining text
39
+ out = htmlEscape(out);
40
+ // Step 4: Convert --- → blank line
34
41
  out = out.replace(/^-{3,}$/gm, "");
35
- // Step 4: Convert ## headings → *bold*
36
- out = out.replace(/^#{1,6}\s+(.+)$/gm, "*$1*");
37
- // Step 5: Convert **bold** → *bold*
38
- out = out.replace(/\*\*(.+?)\*\*/gs, "*$1*");
39
- // Step 6: Convert - list items → • item (leading - or * bullet)
42
+ // Step 5: Convert ## headings → <b>Heading</b>
43
+ out = out.replace(/^#{1,6}\s+(.+)$/gm, "<b>$1</b>");
44
+ // Step 6: Convert **bold** → <b>bold</b>
45
+ out = out.replace(/\*\*(.+?)\*\*/gs, "<b>$1</b>");
46
+ // Step 7: Convert - item / * item → • item
40
47
  out = out.replace(/^[ \t]*[-*]\s+(.+)$/gm, "• $1");
41
- // Step 7: Escape MarkdownV2 special chars outside code blocks.
42
- // Per Telegram spec, these must be escaped: _ [ ] ( ) ~ > # + - = | { } . ! \
43
- // * is intentionally NOT escaped — it is used for bold formatting above.
44
- out = out.replace(/([_\[\]()~>#+\-=|{}.!\\])/g, "\\$1");
45
- // Step 8: Reinsert code blocks unchanged (no escaping inside them)
48
+ // Step 8: Convert *bold* <b>bold</b> (single asterisk, after bullets handled)
49
+ out = out.replace(/\*([^*\n]+)\*/g, "<b>$1</b>");
50
+ // Step 9: Convert _italic_ <i>italic</i>
51
+ // Use word-boundary guards to avoid mangling snake_case identifiers
52
+ out = out.replace(/(?<![a-zA-Z0-9])_([^_\n]+?)_(?![a-zA-Z0-9])/g, "<i>$1</i>");
53
+ // Step 10: Reinsert code blocks
46
54
  out = out.replace(/\x00P(\d+)\x00/g, (_, i) => placeholders[parseInt(i, 10)]);
47
55
  return out;
48
56
  }
57
+ function findPreRanges(text) {
58
+ const ranges = [];
59
+ const open = "<pre>";
60
+ const close = "</pre>";
61
+ let i = 0;
62
+ while (i < text.length) {
63
+ const start = text.indexOf(open, i);
64
+ if (start === -1)
65
+ break;
66
+ const end = text.indexOf(close, start);
67
+ if (end === -1)
68
+ break;
69
+ ranges.push([start, end + close.length]);
70
+ i = end + close.length;
71
+ }
72
+ return ranges;
73
+ }
74
+ function isInsidePre(pos, ranges) {
75
+ return ranges.some(([start, end]) => pos > start && pos < end);
76
+ }
49
77
  /**
50
78
  * Split a long message at natural boundaries (paragraph > line > word).
51
- * Never splits mid-word. Chunks are at most maxLen characters.
79
+ * Never splits mid-word or inside <pre> blocks. Chunks are at most maxLen characters.
52
80
  */
53
81
  export function splitLongMessage(text, maxLen = 4096) {
54
82
  if (text.length <= maxLen)
@@ -57,6 +85,7 @@ export function splitLongMessage(text, maxLen = 4096) {
57
85
  let remaining = text;
58
86
  while (remaining.length > maxLen) {
59
87
  const slice = remaining.slice(0, maxLen);
88
+ const preRanges = findPreRanges(remaining);
60
89
  // Prefer paragraph boundary (\n\n)
61
90
  const lastPara = slice.lastIndexOf("\n\n");
62
91
  // Then line boundary (\n)
@@ -64,17 +93,24 @@ export function splitLongMessage(text, maxLen = 4096) {
64
93
  // Then word boundary (space)
65
94
  const lastSpace = slice.lastIndexOf(" ");
66
95
  let splitAt;
67
- if (lastPara > 0) {
96
+ if (lastPara > 0 && !isInsidePre(lastPara, preRanges)) {
68
97
  splitAt = lastPara + 2;
69
98
  }
70
- else if (lastLine > 0) {
99
+ else if (lastLine > 0 && !isInsidePre(lastLine, preRanges)) {
71
100
  splitAt = lastLine + 1;
72
101
  }
73
- else if (lastSpace > 0) {
102
+ else if (lastSpace > 0 && !isInsidePre(lastSpace, preRanges)) {
74
103
  splitAt = lastSpace + 1;
75
104
  }
76
105
  else {
77
- splitAt = maxLen;
106
+ // If all candidate split points are inside a <pre> block, split after it
107
+ const coveringPre = preRanges.find(([start, end]) => start < maxLen && end > maxLen);
108
+ if (coveringPre) {
109
+ splitAt = coveringPre[1];
110
+ }
111
+ else {
112
+ splitAt = maxLen;
113
+ }
78
114
  }
79
115
  chunks.push(remaining.slice(0, splitAt).trimEnd());
80
116
  remaining = remaining.slice(splitAt).trimStart();
package/dist/index.js CHANGED
@@ -15,10 +15,17 @@
15
15
  * CWD — working directory for Claude Code (default: process.cwd())
16
16
  */
17
17
  import { createServer, createConnection } from "net";
18
- import { unlinkSync } from "fs";
18
+ import { unlinkSync, readFileSync } from "fs";
19
19
  import { tmpdir } from "os";
20
- import { join } from "path";
20
+ import os from "os";
21
+ import { join, dirname } from "path";
22
+ import { fileURLToPath } from "url";
21
23
  import { CcTgBot } from "./bot.js";
24
+ import { Registry, startControlServer } from "@gonzih/agent-ops";
25
+ import { Redis } from "ioredis";
26
+ const __filename = fileURLToPath(import.meta.url);
27
+ const __dirname = dirname(__filename);
28
+ const pkg = JSON.parse(readFileSync(join(__dirname, "../package.json"), "utf-8"));
22
29
  // Make lock socket unique per bot token so multiple users on the same machine don't collide
23
30
  const _tokenHash = Buffer.from(process.env.TELEGRAM_BOT_TOKEN ?? "default").toString("base64").replace(/[^a-z0-9]/gi, "").slice(0, 16);
24
31
  const LOCK_SOCKET = join(tmpdir(), `cc-tg-${_tokenHash}.sock`);
@@ -105,6 +112,31 @@ const bot = new CcTgBot({
105
112
  allowedUserIds,
106
113
  groupChatIds,
107
114
  });
115
+ // agent-ops: optional self-registration + HTTP control endpoint
116
+ if (process.env.CC_AGENT_OPS_PORT) {
117
+ const botInfo = await bot.getMe();
118
+ const redis = new Redis(process.env.REDIS_URL || "redis://localhost:6379");
119
+ const registry = new Registry(redis);
120
+ const namespace = process.env.CC_AGENT_NAMESPACE || "default";
121
+ await registry.register({
122
+ namespace,
123
+ hostname: os.hostname(),
124
+ user: os.userInfo().username,
125
+ pid: String(process.pid),
126
+ version: pkg.version,
127
+ cwd: process.env.CWD || process.cwd(),
128
+ control_port: process.env.CC_AGENT_OPS_PORT,
129
+ bot_username: botInfo.username ?? "",
130
+ started_at: new Date().toISOString(),
131
+ });
132
+ setInterval(() => registry.heartbeat(namespace), 60_000);
133
+ startControlServer(Number(process.env.CC_AGENT_OPS_PORT), {
134
+ namespace,
135
+ version: pkg.version,
136
+ logFile: process.env.CC_AGENT_LOG_FILE || process.env.LOG_FILE,
137
+ });
138
+ console.log(`[ops] control server on port ${process.env.CC_AGENT_OPS_PORT}`);
139
+ }
108
140
  process.on("SIGINT", () => {
109
141
  console.log("\nShutting down...");
110
142
  bot.stop();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gonzih/cc-tg",
3
- "version": "0.3.13",
3
+ "version": "0.3.15",
4
4
  "description": "Claude Code Telegram bot — chat with Claude Code via Telegram",
5
5
  "type": "module",
6
6
  "bin": {
@@ -18,6 +18,7 @@
18
18
  "dist/"
19
19
  ],
20
20
  "dependencies": {
21
+ "@gonzih/agent-ops": "^0.1.0",
21
22
  "node-telegram-bot-api": "^0.66.0"
22
23
  },
23
24
  "devDependencies": {