ccbot 1.0.0

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.
@@ -0,0 +1,126 @@
1
+ import { execSync } from "node:child_process";
2
+ import { parseTranscript } from "../monitor/transcript-parser.js";
3
+ import { formatNotification, extractProjectName, } from "../telegram/message-formatter.js";
4
+ import { formatError } from "../utils/error-utils.js";
5
+ const GIT_TIMEOUT_MS = 10_000;
6
+ function isValidStopEvent(data) {
7
+ if (typeof data !== "object" || data === null)
8
+ return false;
9
+ const obj = data;
10
+ return typeof obj.session_id === "string"
11
+ && typeof obj.transcript_path === "string"
12
+ && typeof obj.cwd === "string";
13
+ }
14
+ export class HookHandler {
15
+ notify;
16
+ constructor(notify) {
17
+ this.notify = notify;
18
+ }
19
+ handleStopEvent(event) {
20
+ if (!isValidStopEvent(event)) {
21
+ console.log("ccbot: invalid stop event payload — missing required fields");
22
+ return;
23
+ }
24
+ console.log(`ccbot: stop event received for session ${event.session_id} at ${event.cwd}`);
25
+ let summary = { lastAssistantMessage: "", durationMs: 0, totalCostUSD: 0 };
26
+ try {
27
+ summary = parseTranscript(event.transcript_path);
28
+ }
29
+ catch (err) {
30
+ console.log(`ccbot: failed to parse transcript: ${formatError(err)}`);
31
+ }
32
+ const gitChanges = this.collectGitChanges(event.cwd);
33
+ let durationMs = summary.durationMs;
34
+ if (durationMs === 0 && summary.lastAssistantMessage) {
35
+ durationMs = 1000;
36
+ }
37
+ const notification = formatNotification({
38
+ projectName: extractProjectName(event.cwd),
39
+ responseSummary: summary.lastAssistantMessage,
40
+ durationMs,
41
+ gitChanges,
42
+ });
43
+ this.notify(notification).catch((err) => {
44
+ console.log(`ccbot: failed to send notification: ${formatError(err)}`);
45
+ });
46
+ }
47
+ collectGitChanges(cwd) {
48
+ try {
49
+ const diffOutput = execSync("git diff --name-status HEAD", {
50
+ cwd,
51
+ encoding: "utf-8",
52
+ timeout: GIT_TIMEOUT_MS,
53
+ });
54
+ const changes = this.parseGitDiffOutput(diffOutput);
55
+ try {
56
+ const untrackedOutput = execSync("git ls-files --others --exclude-standard", {
57
+ cwd,
58
+ encoding: "utf-8",
59
+ timeout: GIT_TIMEOUT_MS,
60
+ });
61
+ for (const file of untrackedOutput.trim().split("\n")) {
62
+ if (file)
63
+ changes.push({ file, status: "added" });
64
+ }
65
+ }
66
+ catch { }
67
+ return changes;
68
+ }
69
+ catch {
70
+ try {
71
+ const porcelainOutput = execSync("git status --porcelain", {
72
+ cwd,
73
+ encoding: "utf-8",
74
+ timeout: GIT_TIMEOUT_MS,
75
+ });
76
+ return this.parsePorcelainOutput(porcelainOutput);
77
+ }
78
+ catch {
79
+ return [];
80
+ }
81
+ }
82
+ }
83
+ parseGitDiffOutput(output) {
84
+ const changes = [];
85
+ for (const line of output.trim().split("\n")) {
86
+ if (!line)
87
+ continue;
88
+ const parts = line.split("\t");
89
+ if (parts.length < 2)
90
+ continue;
91
+ let status = "modified";
92
+ if (parts[0].startsWith("A"))
93
+ status = "added";
94
+ else if (parts[0].startsWith("D"))
95
+ status = "deleted";
96
+ else if (parts[0].startsWith("R"))
97
+ status = "renamed";
98
+ changes.push({ file: parts[1], status });
99
+ }
100
+ return changes;
101
+ }
102
+ parsePorcelainOutput(output) {
103
+ const changes = [];
104
+ for (const line of output.trim().split("\n")) {
105
+ if (line.length < 4)
106
+ continue;
107
+ const statusCode = line.slice(0, 2).trim();
108
+ const file = line.slice(3).trim();
109
+ let status = "modified";
110
+ switch (statusCode) {
111
+ case "??":
112
+ case "A":
113
+ status = "added";
114
+ break;
115
+ case "D":
116
+ status = "deleted";
117
+ break;
118
+ case "R":
119
+ status = "renamed";
120
+ break;
121
+ }
122
+ changes.push({ file, status });
123
+ }
124
+ return changes;
125
+ }
126
+ }
@@ -0,0 +1,11 @@
1
+ export declare class HookInstaller {
2
+ private static readonly HOOKS_DIR;
3
+ private static readonly SCRIPT_PATH;
4
+ private static readonly SETTINGS_PATH;
5
+ static install(hookPort: number, hookSecret: string): void;
6
+ static uninstall(): void;
7
+ private static installScript;
8
+ private static removeScript;
9
+ private static removeFromSettings;
10
+ private static readSettings;
11
+ }
@@ -0,0 +1,97 @@
1
+ import { readFileSync, writeFileSync, mkdirSync, unlinkSync, rmdirSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { homedir } from "node:os";
4
+ export class HookInstaller {
5
+ static HOOKS_DIR = join(homedir(), ".ccbot", "hooks");
6
+ static SCRIPT_PATH = join(HookInstaller.HOOKS_DIR, "stop-notify.sh");
7
+ static SETTINGS_PATH = join(homedir(), ".claude", "settings.json");
8
+ static install(hookPort, hookSecret) {
9
+ if (!Number.isInteger(hookPort) || hookPort < 1 || hookPort > 65535) {
10
+ throw new Error(`invalid hook port: ${hookPort} (must be 1-65535)`);
11
+ }
12
+ const settings = HookInstaller.readSettings();
13
+ const hooks = (settings.hooks ?? {});
14
+ const existingStop = (hooks.Stop ?? []);
15
+ const alreadyInstalled = existingStop.some((entry) => {
16
+ const entryHooks = entry.hooks;
17
+ return entryHooks?.some((h) => typeof h.command === "string" && h.command.includes("ccbot"));
18
+ });
19
+ if (alreadyInstalled) {
20
+ throw new Error("ccbot hook already installed");
21
+ }
22
+ existingStop.push({
23
+ hooks: [{ type: "command", command: HookInstaller.SCRIPT_PATH, timeout: 10 }],
24
+ });
25
+ hooks.Stop = existingStop;
26
+ settings.hooks = hooks;
27
+ mkdirSync(join(homedir(), ".claude"), { recursive: true });
28
+ writeFileSync(HookInstaller.SETTINGS_PATH, JSON.stringify(settings, null, 2));
29
+ HookInstaller.installScript(hookPort, hookSecret);
30
+ }
31
+ static uninstall() {
32
+ HookInstaller.removeFromSettings();
33
+ HookInstaller.removeScript();
34
+ }
35
+ static installScript(hookPort, hookSecret) {
36
+ mkdirSync(HookInstaller.HOOKS_DIR, { recursive: true });
37
+ const script = `#!/bin/bash
38
+ curl -s -X POST http://localhost:${hookPort}/hook/stop \\
39
+ -H "Content-Type: application/json" \\
40
+ -H "X-CCBot-Secret: ${hookSecret}" \\
41
+ --data-binary @- > /dev/null 2>&1 || true
42
+ `;
43
+ writeFileSync(HookInstaller.SCRIPT_PATH, script, { mode: 0o755 });
44
+ }
45
+ static removeScript() {
46
+ try {
47
+ unlinkSync(HookInstaller.SCRIPT_PATH);
48
+ }
49
+ catch { }
50
+ try {
51
+ rmdirSync(HookInstaller.HOOKS_DIR);
52
+ }
53
+ catch { }
54
+ }
55
+ static removeFromSettings() {
56
+ let data;
57
+ try {
58
+ data = readFileSync(HookInstaller.SETTINGS_PATH, "utf-8");
59
+ }
60
+ catch {
61
+ return;
62
+ }
63
+ const settings = JSON.parse(data);
64
+ const hooks = settings.hooks;
65
+ if (!hooks)
66
+ return;
67
+ const existingStop = hooks.Stop;
68
+ if (!existingStop)
69
+ return;
70
+ const filtered = existingStop.filter((entry) => {
71
+ const entryHooks = entry.hooks;
72
+ return !entryHooks?.some((h) => typeof h.command === "string" && h.command.includes("ccbot"));
73
+ });
74
+ if (filtered.length === 0) {
75
+ delete hooks.Stop;
76
+ }
77
+ else {
78
+ hooks.Stop = filtered;
79
+ }
80
+ if (Object.keys(hooks).length === 0) {
81
+ delete settings.hooks;
82
+ }
83
+ writeFileSync(HookInstaller.SETTINGS_PATH, JSON.stringify(settings, null, 2));
84
+ }
85
+ static readSettings() {
86
+ try {
87
+ const data = readFileSync(HookInstaller.SETTINGS_PATH, "utf-8");
88
+ return JSON.parse(data);
89
+ }
90
+ catch (err) {
91
+ if (err instanceof Error && "code" in err && err.code === "ENOENT") {
92
+ return {};
93
+ }
94
+ throw new Error(`read settings: ${err instanceof Error ? err.message : String(err)}`);
95
+ }
96
+ }
97
+ }
@@ -0,0 +1,12 @@
1
+ import { HookHandler } from "./hook-handler.js";
2
+ export declare class HookServer {
3
+ private app;
4
+ private server;
5
+ private port;
6
+ private secret;
7
+ private handler;
8
+ constructor(port: number, secret: string, handler: HookHandler);
9
+ start(): void;
10
+ stop(): Promise<void>;
11
+ private createApp;
12
+ }
@@ -0,0 +1,44 @@
1
+ import express from "express";
2
+ export class HookServer {
3
+ app;
4
+ server = null;
5
+ port;
6
+ secret;
7
+ handler;
8
+ constructor(port, secret, handler) {
9
+ this.port = port;
10
+ this.secret = secret;
11
+ this.handler = handler;
12
+ this.app = this.createApp();
13
+ }
14
+ start() {
15
+ this.server = this.app.listen(this.port, "127.0.0.1", () => {
16
+ console.log(`ccbot: hook server listening on localhost:${this.port}`);
17
+ });
18
+ }
19
+ stop() {
20
+ return new Promise((resolve) => {
21
+ if (!this.server) {
22
+ resolve();
23
+ return;
24
+ }
25
+ this.server.close(() => resolve());
26
+ });
27
+ }
28
+ createApp() {
29
+ const app = express();
30
+ app.use(express.json({ limit: "10mb" }));
31
+ app.post("/hook/stop", (req, res) => {
32
+ if (req.headers["x-ccbot-secret"] !== this.secret) {
33
+ res.status(403).send("forbidden");
34
+ return;
35
+ }
36
+ setImmediate(() => this.handler.handleStopEvent(req.body));
37
+ res.status(200).send("ok");
38
+ });
39
+ app.get("/health", (_req, res) => {
40
+ res.status(200).send("healthy");
41
+ });
42
+ return app;
43
+ }
44
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,59 @@
1
+ #!/usr/bin/env node
2
+ import { ConfigManager } from "./config-manager.js";
3
+ import { Bot } from "./telegram/bot.js";
4
+ import { HookServer } from "./hook/hook-server.js";
5
+ import { HookHandler } from "./hook/hook-handler.js";
6
+ import { runSetup } from "./commands/setup.js";
7
+ import { runUpdate } from "./commands/update.js";
8
+ import { runUninstall } from "./commands/uninstall.js";
9
+ import { runHelp } from "./commands/help.js";
10
+ import { formatError } from "./utils/error-utils.js";
11
+ const args = process.argv.slice(2);
12
+ if (args.length > 0) {
13
+ handleSubcommand(args);
14
+ }
15
+ else {
16
+ startBot();
17
+ }
18
+ function startBot() {
19
+ const cfg = ConfigManager.load();
20
+ const bot = new Bot(cfg);
21
+ const handler = new HookHandler((text) => bot.sendNotification(text));
22
+ const hookServer = new HookServer(cfg.hook_port, cfg.hook_secret, handler);
23
+ hookServer.start();
24
+ console.log(`ccbot: started (hook port: ${cfg.hook_port})`);
25
+ bot.start();
26
+ const shutdown = async () => {
27
+ console.log("\nccbot: shutting down...");
28
+ bot.stop();
29
+ await hookServer.stop();
30
+ process.exit(0);
31
+ };
32
+ process.on("SIGINT", shutdown);
33
+ process.on("SIGTERM", shutdown);
34
+ }
35
+ function handleSubcommand(args) {
36
+ switch (args[0]) {
37
+ case "setup":
38
+ runSetup().catch((err) => {
39
+ console.error(`ccbot: setup failed: ${formatError(err)}`);
40
+ process.exit(1);
41
+ });
42
+ break;
43
+ case "update":
44
+ runUpdate();
45
+ break;
46
+ case "uninstall":
47
+ runUninstall();
48
+ break;
49
+ case "help":
50
+ case "--help":
51
+ case "-h":
52
+ runHelp();
53
+ break;
54
+ default:
55
+ console.error(`unknown command: ${args[0]}`);
56
+ runHelp();
57
+ process.exit(1);
58
+ }
59
+ }
@@ -0,0 +1,6 @@
1
+ export interface TranscriptSummary {
2
+ lastAssistantMessage: string;
3
+ durationMs: number;
4
+ totalCostUSD: number;
5
+ }
6
+ export declare function parseTranscript(transcriptPath: string): TranscriptSummary;
@@ -0,0 +1,56 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ export function parseTranscript(transcriptPath) {
4
+ const expandedPath = expandHome(transcriptPath);
5
+ const raw = readFileSync(expandedPath, "utf-8");
6
+ const lines = raw.split("\n");
7
+ let lastAssistantText = "";
8
+ let totalCost = 0;
9
+ let firstTimestamp = null;
10
+ let lastTimestamp = null;
11
+ for (const line of lines) {
12
+ if (!line.trim())
13
+ continue;
14
+ let entry;
15
+ try {
16
+ entry = JSON.parse(line);
17
+ }
18
+ catch {
19
+ continue;
20
+ }
21
+ if (entry.timestamp) {
22
+ const t = new Date(entry.timestamp);
23
+ if (!isNaN(t.getTime())) {
24
+ if (!firstTimestamp)
25
+ firstTimestamp = t;
26
+ lastTimestamp = t;
27
+ }
28
+ }
29
+ totalCost += entry.costUSD ?? 0;
30
+ if (entry.type === "assistant" || entry.message) {
31
+ const msg = entry.message;
32
+ if (msg?.role === "assistant") {
33
+ const text = extractTextFromContent(msg.content ?? []);
34
+ if (text)
35
+ lastAssistantText = text;
36
+ }
37
+ }
38
+ }
39
+ let durationMs = 0;
40
+ if (firstTimestamp && lastTimestamp) {
41
+ durationMs = lastTimestamp.getTime() - firstTimestamp.getTime();
42
+ }
43
+ return { lastAssistantMessage: lastAssistantText, durationMs, totalCostUSD: totalCost };
44
+ }
45
+ function extractTextFromContent(parts) {
46
+ return parts
47
+ .filter((p) => p.type === "text" && p.text)
48
+ .map((p) => p.text)
49
+ .join("\n");
50
+ }
51
+ function expandHome(path) {
52
+ if (path.startsWith("~/")) {
53
+ return homedir() + path.slice(1);
54
+ }
55
+ return path;
56
+ }
@@ -0,0 +1 @@
1
+ export declare function runSetup(): Promise<void>;
package/dist/setup.js ADDED
@@ -0,0 +1,130 @@
1
+ import { createInterface } from "node:readline";
2
+ import { mkdirSync, writeFileSync, readFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { homedir } from "node:os";
5
+ import { ConfigManager } from "./config-manager.js";
6
+ import { HookInstaller } from "./hook/hook-installer.js";
7
+ import { formatError } from "./utils/error-utils.js";
8
+ export async function runSetup() {
9
+ console.log("🤖 ccbot setup");
10
+ console.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
11
+ console.log();
12
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
13
+ const question = (prompt) => new Promise((resolve) => rl.question(prompt, resolve));
14
+ let existing = null;
15
+ try {
16
+ existing = ConfigManager.load();
17
+ }
18
+ catch { }
19
+ try {
20
+ const token = await promptBotToken(question, existing);
21
+ const userId = await promptUserID(question, existing);
22
+ const hookPort = existing?.hook_port || 9377;
23
+ const hookSecret = existing?.hook_secret || ConfigManager.generateSecret();
24
+ const cfg = {
25
+ telegram_bot_token: token,
26
+ user_id: userId,
27
+ hook_port: hookPort,
28
+ hook_secret: hookSecret,
29
+ };
30
+ ConfigManager.save(cfg);
31
+ console.log("✅ Config saved");
32
+ console.log();
33
+ console.log("📎 Installing Claude Code hook...");
34
+ try {
35
+ HookInstaller.install(cfg.hook_port, cfg.hook_secret);
36
+ console.log("✅ Hook installed → ~/.claude/settings.json");
37
+ }
38
+ catch (err) {
39
+ const msg = formatError(err);
40
+ if (msg.includes("already installed")) {
41
+ console.log("✅ Hook already installed");
42
+ }
43
+ else {
44
+ throw new Error(`install hook: ${msg}`);
45
+ }
46
+ }
47
+ registerChatId(userId);
48
+ const startCommand = detectStartCommand();
49
+ console.log();
50
+ console.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
51
+ console.log("🎉 Setup complete!");
52
+ console.log();
53
+ console.log("Next steps:");
54
+ console.log(` 1. Start bot: ${startCommand}`);
55
+ console.log(" 2. Use Claude Code normally → notifications will arrive");
56
+ console.log();
57
+ }
58
+ finally {
59
+ rl.close();
60
+ }
61
+ }
62
+ async function promptBotToken(question, existing) {
63
+ let hint = "";
64
+ if (existing?.telegram_bot_token) {
65
+ hint = ` [current: ${maskToken(existing.telegram_bot_token)}]`;
66
+ }
67
+ console.log(`Telegram Bot Token${hint}`);
68
+ console.log(" (Get from @BotFather → /newbot)");
69
+ const input = (await question(" → ")).trim();
70
+ if (!input && existing?.telegram_bot_token) {
71
+ return existing.telegram_bot_token;
72
+ }
73
+ if (!input) {
74
+ throw new Error("bot token is required");
75
+ }
76
+ if (!input.includes(":")) {
77
+ throw new Error("invalid bot token format (expected: 123456:ABC-xxx)");
78
+ }
79
+ return input;
80
+ }
81
+ async function promptUserID(question, existing) {
82
+ let hint = "";
83
+ if (existing?.user_id) {
84
+ hint = ` [current: ${existing.user_id}]`;
85
+ }
86
+ console.log();
87
+ console.log(`Your Telegram User ID${hint}`);
88
+ console.log(" (Send /start to @userinfobot to get your ID)");
89
+ const input = (await question(" → ")).trim();
90
+ if (!input && existing?.user_id) {
91
+ return existing.user_id;
92
+ }
93
+ if (!input) {
94
+ throw new Error("user ID is required");
95
+ }
96
+ const id = parseInt(input, 10);
97
+ if (isNaN(id)) {
98
+ throw new Error("invalid user ID — must be a number");
99
+ }
100
+ return id;
101
+ }
102
+ function maskToken(token) {
103
+ const parts = token.split(":");
104
+ if (parts.length !== 2)
105
+ return "***";
106
+ const suffix = parts[1].length > 4 ? parts[1].slice(0, 4) + "..." : parts[1];
107
+ return `***:${suffix}`;
108
+ }
109
+ function registerChatId(userId) {
110
+ const stateDir = join(homedir(), ".ccbot");
111
+ const stateFile = join(stateDir, "state.json");
112
+ let state = { chat_id: null };
113
+ try {
114
+ const data = readFileSync(stateFile, "utf-8");
115
+ state = JSON.parse(data);
116
+ }
117
+ catch { }
118
+ if (state.chat_id === userId) {
119
+ return;
120
+ }
121
+ state.chat_id = userId;
122
+ mkdirSync(stateDir, { recursive: true });
123
+ writeFileSync(stateFile, JSON.stringify(state, null, 2), { mode: 0o600 });
124
+ console.log(`✅ Chat ID registered`);
125
+ }
126
+ function detectStartCommand() {
127
+ const execPath = process.argv[1] ?? "";
128
+ const isNpx = execPath.includes("npx") || execPath.includes(".npm/_npx");
129
+ return isNpx ? "npx ccbot" : "ccbot";
130
+ }
@@ -0,0 +1,15 @@
1
+ import { type Config } from "../config-manager.js";
2
+ export declare class Bot {
3
+ private bot;
4
+ private cfg;
5
+ private chatId;
6
+ private static readonly STATE_DIR;
7
+ private static readonly STATE_FILE;
8
+ constructor(cfg: Config);
9
+ start(): void;
10
+ stop(): Promise<void>;
11
+ sendNotification(text: string): Promise<void>;
12
+ private registerHandlers;
13
+ private loadState;
14
+ private saveState;
15
+ }
@@ -0,0 +1,79 @@
1
+ import TelegramBot from "node-telegram-bot-api";
2
+ import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { homedir } from "node:os";
5
+ import { ConfigManager } from "../config-manager.js";
6
+ import { sendMessage } from "./message-sender.js";
7
+ import { formatError } from "../utils/error-utils.js";
8
+ export class Bot {
9
+ bot;
10
+ cfg;
11
+ chatId = null;
12
+ static STATE_DIR = join(homedir(), ".ccbot");
13
+ static STATE_FILE = join(Bot.STATE_DIR, "state.json");
14
+ constructor(cfg) {
15
+ this.cfg = cfg;
16
+ this.bot = new TelegramBot(cfg.telegram_bot_token, { polling: false });
17
+ this.loadState();
18
+ this.registerHandlers();
19
+ }
20
+ start() {
21
+ this.bot.startPolling();
22
+ console.log("ccbot: telegram bot started");
23
+ }
24
+ stop() {
25
+ return new Promise((resolve) => {
26
+ this.bot.stopPolling();
27
+ resolve();
28
+ });
29
+ }
30
+ async sendNotification(text) {
31
+ if (!this.chatId) {
32
+ console.log("ccbot: no chat ID yet — run 'ccbot setup' or send /start to the bot");
33
+ return;
34
+ }
35
+ try {
36
+ await sendMessage(this.bot, this.chatId, text);
37
+ }
38
+ catch (err) {
39
+ console.log(`ccbot: failed to send notification: ${formatError(err)}`);
40
+ }
41
+ }
42
+ registerHandlers() {
43
+ this.bot.on("message", (msg) => {
44
+ if (!ConfigManager.isOwner(this.cfg, msg.from?.id ?? 0)) {
45
+ console.log(`ccbot: unauthorized user ${msg.from?.id} (${msg.from?.username})`);
46
+ return;
47
+ }
48
+ const text = msg.text ?? "";
49
+ if (text === "/start") {
50
+ this.chatId = msg.chat.id;
51
+ this.saveState();
52
+ console.log(`ccbot: registered chat ID ${msg.chat.id}`);
53
+ this.bot.sendMessage(msg.chat.id, "✅ *ccbot* đã sẵn sàng\\.\\n\\nBạn sẽ nhận notification khi Claude Code hoàn thành response\\.", { parse_mode: "MarkdownV2" });
54
+ return;
55
+ }
56
+ if (text === "/ping") {
57
+ this.bot.sendMessage(msg.chat.id, "pong 🏓");
58
+ return;
59
+ }
60
+ if (text === "/status") {
61
+ this.bot.sendMessage(msg.chat.id, "🟢 ccbot đang chạy");
62
+ return;
63
+ }
64
+ });
65
+ }
66
+ loadState() {
67
+ try {
68
+ const data = readFileSync(Bot.STATE_FILE, "utf-8");
69
+ const state = JSON.parse(data);
70
+ this.chatId = state.chat_id ?? null;
71
+ }
72
+ catch { }
73
+ }
74
+ saveState() {
75
+ const state = { chat_id: this.chatId };
76
+ mkdirSync(Bot.STATE_DIR, { recursive: true });
77
+ writeFileSync(Bot.STATE_FILE, JSON.stringify(state, null, 2), { mode: 0o600 });
78
+ }
79
+ }
@@ -0,0 +1,13 @@
1
+ export interface GitChange {
2
+ file: string;
3
+ status: "modified" | "added" | "deleted" | "renamed";
4
+ }
5
+ export interface NotificationData {
6
+ projectName: string;
7
+ responseSummary: string;
8
+ durationMs: number;
9
+ gitChanges: GitChange[];
10
+ }
11
+ export declare function formatNotification(data: NotificationData): string;
12
+ export declare function escapeMarkdownV2(text: string): string;
13
+ export declare function extractProjectName(cwd: string): string;