@gonzih/cc-tg 0.3.13 → 0.3.14

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
@@ -524,12 +524,12 @@ export class CcTgBot {
524
524
  return;
525
525
  const text = session.isRetry ? `✅ Claude is back!\n\n${raw}` : raw;
526
526
  session.isRetry = false;
527
- // Format for Telegram MarkdownV2 and split if needed (max 4096 chars)
527
+ // Format for Telegram HTML and split if needed (max 4096 chars)
528
528
  const formatted = formatForTelegram(text);
529
529
  const chunks = splitLongMessage(formatted);
530
530
  for (const chunk of chunks) {
531
- this.bot.sendMessage(chatId, chunk, { parse_mode: "MarkdownV2" }).catch(() => {
532
- // MarkdownV2 parse failed — retry as plain text
531
+ this.bot.sendMessage(chatId, chunk, { parse_mode: "HTML" }).catch(() => {
532
+ // HTML parse failed — retry as plain text
533
533
  this.bot.sendMessage(chatId, chunk).catch((err) => console.error(`[tg:${chatId}] send failed:`, err.message));
534
534
  });
535
535
  }
@@ -763,10 +763,10 @@ export class CcTgBot {
763
763
  (async () => {
764
764
  for (const chunk of chunks) {
765
765
  try {
766
- await this.bot.sendMessage(chatId, chunk, { parse_mode: "MarkdownV2" });
766
+ await this.bot.sendMessage(chatId, chunk, { parse_mode: "HTML" });
767
767
  }
768
768
  catch {
769
- // MarkdownV2 parse failed — retry as plain text
769
+ // HTML parse failed — retry as plain text
770
770
  try {
771
771
  await this.bot.sendMessage(chatId, chunk);
772
772
  }
@@ -1147,6 +1147,9 @@ export class CcTgBot {
1147
1147
  if (!keepCrons)
1148
1148
  this.cron.clearAll(chatId);
1149
1149
  }
1150
+ getMe() {
1151
+ return this.bot.getMe();
1152
+ }
1150
1153
  stop() {
1151
1154
  this.bot.stopPolling();
1152
1155
  for (const [chatId] of this.sessions) {
@@ -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.14",
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": {