claude-telegram-bot 0.2.6 → 0.2.7

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.ko.md CHANGED
@@ -2,6 +2,10 @@
2
2
 
3
3
  **한국어** · [English](./README.md)
4
4
 
5
+ [![npm version](https://img.shields.io/npm/v/claude-telegram-bot.svg)](https://www.npmjs.com/package/claude-telegram-bot)
6
+ [![npm downloads](https://img.shields.io/npm/dm/claude-telegram-bot.svg)](https://www.npmjs.com/package/claude-telegram-bot)
7
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
8
+
5
9
  텔레그램으로 메시지를 보내면, 집이나 서버에 켜둔 Claude Code가 작업하고 결과를 다시 텔레그램으로 돌려주는 봇입니다.
6
10
 
7
11
  ```
package/README.md CHANGED
@@ -2,6 +2,10 @@
2
2
 
3
3
  [한국어](./README.ko.md) · **English**
4
4
 
5
+ [![npm version](https://img.shields.io/npm/v/claude-telegram-bot.svg)](https://www.npmjs.com/package/claude-telegram-bot)
6
+ [![npm downloads](https://img.shields.io/npm/dm/claude-telegram-bot.svg)](https://www.npmjs.com/package/claude-telegram-bot)
7
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
8
+
5
9
  **A zero-dependency, single-file, daemonized Claude Code bot — no Bun, no Python, no open session.**
6
10
 
7
11
  A tiny bridge that takes your Telegram messages, runs `claude -p` (Claude Code headless mode)
package/bot.mjs CHANGED
@@ -14,7 +14,7 @@
14
14
  // 자동 판별하고, cfg.lang 을 주면 그 언어로 고정함. 콘솔/CLI 출력은 영어 단일.
15
15
 
16
16
  import { basename, dirname, join } from "node:path";
17
- import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
17
+ import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
18
18
 
19
19
  import dns from "node:dns";
20
20
  import { fileURLToPath } from "node:url";
@@ -134,6 +134,7 @@ const STR = {
134
134
  `\nWorking dir: ${cfg.projectDir}\nPermission mode: ${cfg.permissionMode}`,
135
135
  newSession: "🆕 Started a new conversation (previous context cleared).",
136
136
  busy: "⏳ A previous task is still running. Please try again when it finishes.",
137
+ localBusy: "💻 A local `ctb claude` session is active. Send a message when it's done.",
137
138
  needChatId: (id) => `Add this chat ID to "allowedChatId" in config.json:\n${id}`,
138
139
  cronEmpty:
139
140
  "No scheduled tasks yet.\nAdd one in plain language, e.g. `/cron add summarize open issues every weekday at 9am`.",
@@ -189,6 +190,7 @@ const STR = {
189
190
  `\n작업 폴더: ${cfg.projectDir}\n권한 모드: ${cfg.permissionMode}`,
190
191
  newSession: "🆕 새 대화를 시작합니다 (이전 맥락 초기화).",
191
192
  busy: "⏳ 이전 작업이 아직 진행 중입니다. 끝나면 다시 보내주세요.",
193
+ localBusy: "💻 로컬 `ctb claude` 세션이 활성화되어 있습니다. 종료 후 메시지를 보내주세요.",
192
194
  needChatId: (id) => `이 채팅 ID를 config.json 의 allowedChatId 에 넣으세요:\n${id}`,
193
195
  cronEmpty:
194
196
  "등록된 예약 작업이 없습니다.\n`/cron add 매일 아침 9시에 …` 처럼 자연어로 추가해 보세요.",
@@ -237,7 +239,7 @@ const t = (l, key, ...a) => {
237
239
  };
238
240
 
239
241
  // /model 에서 보여줄 추천 별칭(claude CLI 가 별칭·전체 모델 ID 모두 허용).
240
- const MODEL_SUGGESTIONS = ["opus", "sonnet", "haiku"];
242
+ const MODEL_SUGGESTIONS = ["fable", "opus", "sonnet", "haiku"];
241
243
 
242
244
  // /(슬래시) 자동완성 메뉴용 명령 목록 (언어별). setMyCommands 로 등록.
243
245
  const COMMANDS = {
@@ -261,6 +263,23 @@ const COMMANDS = {
261
263
  ],
262
264
  };
263
265
 
266
+ // ── 로컬 세션 lock ────────────────────────────────────────────────────────
267
+ // ctb claude 실행 시 .claude-bot/local.lock (PID) 을 생성하고 종료 시 삭제.
268
+ // 봇은 claude 실행 전 이 파일을 확인해 동시 실행을 방지한다.
269
+ // PID 가 이미 종료된 경우(stale lock) 자동 제거 후 진행.
270
+ const LOCAL_LOCK_PATH = join(BOT_DIR, "local.lock");
271
+ function checkLocalLock() {
272
+ if (!existsSync(LOCAL_LOCK_PATH)) return false;
273
+ try {
274
+ const pid = parseInt(readFileSync(LOCAL_LOCK_PATH, "utf8"), 10);
275
+ process.kill(pid, 0); // throws if process is dead
276
+ return true; // lock is valid
277
+ } catch {
278
+ try { unlinkSync(LOCAL_LOCK_PATH); } catch {} // stale — remove
279
+ return false;
280
+ }
281
+ }
282
+
264
283
  // ── 상태 (세션 이어가기용) ────────────────────────────────────────────────
265
284
  function loadState() {
266
285
  // 새 경로(.claude-bot/) 우선, 없으면 구버전 루트 경로로 폴백(이주 실패 시 안전망).
@@ -395,7 +414,7 @@ function runClaude(prompt, sessionId, opts = {}) {
395
414
  "This reply is delivered over Telegram. Be concise — short paragraphs and lists, no filler intro/summary, avoid large tables. Reply in the user's language.";
396
415
  // opts.modelHint: 현재 모델을 주입 → 답변 끝에 상위 모델 권유 제안(판단은 Claude 본인)
397
416
  const modelHint = opts.modelHint
398
- ? `Current model: ${model || "claude (default)"}. Model tiers (low→high): haiku → sonnet → opus. If this question seems to require more capability than the current model, append one short line at the very end of your reply: 💡 \`/model sonnet\` (or \`/model opus\`) for a stronger answer. Omit the suggestion for simple questions.`
417
+ ? `Current model: ${model || "claude (default)"}. Model tiers (low→high): haiku → sonnet → opus → fable. If this question seems to require more capability than the current model, append one short line at the very end of your reply: 💡 \`/model sonnet\` (or \`/model opus\`, \`/model fable\`) for a stronger answer. Omit the suggestion for simple questions.`
399
418
  : null;
400
419
  // 페르소나(cfg.persona) + 간결 지침 + 모델 힌트를 함께 주입 → 멀티 봇(역할별) 운영용
401
420
  const appendSys = [cfg.persona, brevity, modelHint].filter(Boolean).join("\n\n");
@@ -502,7 +521,7 @@ let schedule = buildSchedule();
502
521
  // 예약 작업은 사용자 대화 맥락을 오염시키지 않도록 항상 새 세션으로 독립 실행하고,
503
522
  // 결과를 allowedChatId 로 보낸다. busy 락을 공유해 사용자 요청과 직렬화됨.
504
523
  async function runScheduled(job) {
505
- if (busy) {
524
+ if (busy || checkLocalLock()) {
506
525
  console.warn(`Skipped scheduled job (busy): ${job.cron} — ${String(job.prompt).slice(0, 40)}`);
507
526
  return;
508
527
  }
@@ -769,6 +788,10 @@ async function handle(msg) {
769
788
  await send(chatId, t(l, "busy"));
770
789
  return;
771
790
  }
791
+ if (checkLocalLock()) {
792
+ await send(chatId, t(l, "localBusy"));
793
+ return;
794
+ }
772
795
  busy = true;
773
796
  await tg("sendChatAction", { chat_id: chatId, action: "typing" });
774
797
  const started = Date.now();
package/ctb.mjs ADDED
@@ -0,0 +1,117 @@
1
+ #!/usr/bin/env node
2
+ // ctb — short-form CLI for claude-telegram-bot
3
+ //
4
+ // ctb [config.json] [...claude args] Run Claude, resuming the shared Telegram session
5
+ // ctb bot [config.json] Start the Telegram bot daemon (delegates to bot.mjs)
6
+ // ctb init [dir] Create a config.json template
7
+ // ctb --help | --version
8
+ //
9
+ // config.json is optional. A bare name like "planner.json" resolves relative to the
10
+ // package directory (where bot configs typically live alongside bot.mjs).
11
+ // Absolute or explicitly relative paths (/ or ./) resolve as-is.
12
+ //
13
+ // While Claude runs, .claude-bot/local.lock (PID) is created so the bot defers
14
+ // incoming Telegram messages until the local session ends.
15
+
16
+ import { mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
17
+ import { basename, dirname, join } from "node:path";
18
+ import { fileURLToPath } from "node:url";
19
+ import { spawn } from "node:child_process";
20
+
21
+ const HERE = dirname(fileURLToPath(import.meta.url));
22
+ const args = process.argv.slice(2);
23
+ const a = args[0];
24
+
25
+ const VERSION = (() => {
26
+ try {
27
+ return JSON.parse(readFileSync(join(HERE, "package.json"), "utf8")).version;
28
+ } catch {
29
+ return "?";
30
+ }
31
+ })();
32
+
33
+ function runBot(botArgs) {
34
+ const child = spawn(process.execPath, [join(HERE, "bot.mjs"), ...botArgs], {
35
+ stdio: "inherit",
36
+ });
37
+ child.on("close", (code) => process.exit(code ?? 0));
38
+ }
39
+
40
+ function resolveConfig(arg) {
41
+ if (!arg) return process.env.BOT_CONFIG || join(HERE, "config.json");
42
+ // Absolute or explicitly relative path → use as-is
43
+ if (arg.startsWith("/") || arg.startsWith("./") || arg.startsWith("../"))
44
+ return arg;
45
+ // Bare name (e.g. "planner.json") → relative to package dir
46
+ return join(HERE, arg);
47
+ }
48
+
49
+ function main() {
50
+ if (a === "-h" || a === "--help") {
51
+ console.log(
52
+ `ctb v${VERSION} — claude-telegram-bot short CLI\n\n` +
53
+ `Usage:\n` +
54
+ ` ctb [config.json] [...args] Resume Telegram session and run Claude\n` +
55
+ ` ctb bot [config.json] Start the Telegram bot daemon\n` +
56
+ ` ctb init [dir] Create a config.json template\n` +
57
+ ` ctb --help | --version\n\n` +
58
+ `config.json defaults to $BOT_CONFIG or the package's own config.json.\n` +
59
+ `A bare name like "planner.json" resolves relative to the package directory.\n\n` +
60
+ `Examples:\n` +
61
+ ` ctb Interactive Claude, continuing the Telegram session\n` +
62
+ ` ctb -p "what did we do?" Headless Claude with session context\n` +
63
+ ` ctb planner.json Resume planner persona session interactively\n` +
64
+ ` ctb planner.json -p "..." Headless with planner session\n` +
65
+ ` ctb bot Start the bot with default config\n` +
66
+ ` ctb bot planner.json Start the bot with planner config`,
67
+ );
68
+ process.exit(0);
69
+ }
70
+
71
+ if (a === "-v" || a === "--version") {
72
+ console.log(VERSION);
73
+ process.exit(0);
74
+ }
75
+
76
+ if (a === "init") {
77
+ runBot(args);
78
+ return;
79
+ }
80
+
81
+ if (a === "bot") {
82
+ runBot(args.slice(1));
83
+ return;
84
+ }
85
+
86
+ // Run Claude, resuming the bot's session
87
+ const looksLikeConfig = a && a.endsWith(".json");
88
+ const configPath = resolveConfig(looksLikeConfig ? a : undefined);
89
+ const claudeArgs = looksLikeConfig ? args.slice(1) : args;
90
+
91
+ const dataDir = dirname(configPath);
92
+ const botDir = join(dataDir, ".claude-bot");
93
+ const stateBase = basename(configPath, ".json");
94
+ const stateFile = stateBase === "config" ? "state.json" : `${stateBase}.state.json`;
95
+ const statePath = join(botDir, stateFile);
96
+ const lockPath = join(botDir, "local.lock");
97
+
98
+ mkdirSync(botDir, { recursive: true });
99
+ writeFileSync(lockPath, String(process.pid));
100
+ const cleanup = () => { try { unlinkSync(lockPath); } catch {} };
101
+ process.on("exit", cleanup);
102
+ process.on("SIGINT", () => { cleanup(); process.exit(130); });
103
+ process.on("SIGTERM", () => { cleanup(); process.exit(143); });
104
+
105
+ let sessionId;
106
+ try {
107
+ sessionId = JSON.parse(readFileSync(statePath, "utf8")).sessionId;
108
+ } catch {}
109
+
110
+ const finalArgs = sessionId ? ["--resume", sessionId, ...claudeArgs] : claudeArgs;
111
+ if (sessionId) process.stderr.write(`Resuming session: ${sessionId}\n`);
112
+
113
+ const child = spawn("claude", finalArgs, { stdio: "inherit" });
114
+ child.on("close", (code) => process.exit(code ?? 0));
115
+ }
116
+
117
+ main();
package/package.json CHANGED
@@ -1,13 +1,19 @@
1
1
  {
2
2
  "name": "claude-telegram-bot",
3
- "version": "0.2.6",
3
+ "version": "0.2.7",
4
4
  "description": "Drive Claude Code from Telegram — messages run headless `claude -p` in a project dir and replies come back to the chat. Zero dependencies.",
5
5
  "type": "module",
6
6
  "bin": {
7
- "claude-telegram-bot": "bot.mjs"
7
+ "claude-telegram-bot": "bot.mjs",
8
+ "ctb": "ctb.mjs"
9
+ },
10
+ "exports": {
11
+ "./session.mjs": "./session.mjs"
8
12
  },
9
13
  "files": [
10
14
  "bot.mjs",
15
+ "ctb.mjs",
16
+ "session.mjs",
11
17
  "config.example.json",
12
18
  "com.claudebot.example.plist"
13
19
  ],
package/session.mjs ADDED
@@ -0,0 +1,129 @@
1
+ // Standalone Claude session manager — zero-dependency (Node 18+ built-ins only).
2
+ // Maintains conversational context across calls by persisting --resume session IDs.
3
+ //
4
+ // Usage:
5
+ // import { createSession } from 'claude-telegram-bot/session.mjs'
6
+ //
7
+ // const session = createSession({ projectDir: '/my/project' })
8
+ // const r1 = await session.run('리팩토링 계획 세워줘')
9
+ // const r2 = await session.run('그걸 실제로 실행해줘') // r1 컨텍스트 유지
10
+ // session.reset() // 세션 초기화
11
+ //
12
+ // statePath defaults to <projectDir>/.claude-bot/session.json.
13
+ // To share state with a running bot, pass the bot's state path explicitly:
14
+ // statePath: '/path/to/bot-config-dir/.claude-bot/state.json'
15
+
16
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
17
+ import { dirname, join } from 'node:path'
18
+ import { spawn } from 'node:child_process'
19
+
20
+ function loadState(statePath) {
21
+ try {
22
+ return JSON.parse(readFileSync(statePath, 'utf8'))
23
+ } catch {
24
+ return {}
25
+ }
26
+ }
27
+
28
+ function saveState(statePath, state) {
29
+ try {
30
+ mkdirSync(dirname(statePath), { recursive: true })
31
+ writeFileSync(statePath, JSON.stringify(state, null, 2))
32
+ } catch (e) {
33
+ console.error('Failed to save session state:', e.message)
34
+ }
35
+ }
36
+
37
+ function _runClaude(prompt, sessionId, opts) {
38
+ return new Promise((resolve) => {
39
+ const {
40
+ projectDir = process.cwd(),
41
+ permissionMode = 'acceptEdits',
42
+ model,
43
+ claudeBin = 'claude',
44
+ appendSystemPrompt,
45
+ env = {},
46
+ } = opts
47
+
48
+ const args = [
49
+ '-p', prompt,
50
+ '--output-format', 'json',
51
+ '--permission-mode', permissionMode,
52
+ ]
53
+ if (model) args.push('--model', model)
54
+ if (appendSystemPrompt) args.push('--append-system-prompt', appendSystemPrompt)
55
+ if (sessionId) args.push('--resume', sessionId)
56
+
57
+ const child = spawn(claudeBin, args, {
58
+ cwd: projectDir,
59
+ env: { ...process.env, ...env },
60
+ })
61
+
62
+ let out = '', err = ''
63
+ child.stdout.on('data', (d) => { out += d })
64
+ child.stderr.on('data', (d) => { err += d })
65
+ child.on('error', (e) => resolve({ ok: false, text: `Failed to start claude: ${e.message}` }))
66
+ child.on('close', (code) => {
67
+ try {
68
+ const j = JSON.parse(out)
69
+ resolve({
70
+ ok: !j.is_error,
71
+ text: j.result ?? '(empty response)',
72
+ sessionId: j.session_id,
73
+ cost: j.total_cost_usd,
74
+ })
75
+ } catch {
76
+ resolve({
77
+ ok: false,
78
+ text: `Execution error (exit ${code}):\n${(err || out || 'no output').slice(0, 3500)}`,
79
+ })
80
+ }
81
+ })
82
+ })
83
+ }
84
+
85
+ /**
86
+ * Create a stateful Claude session.
87
+ *
88
+ * @param {object} [opts]
89
+ * @param {string} [opts.projectDir] Working directory for claude (default: process.cwd())
90
+ * @param {string} [opts.statePath] Where to persist the session ID (default: <projectDir>/.claude-bot/session.json; bot uses <configDir>/.claude-bot/state.json)
91
+ * @param {string} [opts.permissionMode] Claude permission mode (default: 'acceptEdits')
92
+ * @param {string} [opts.model] Model override (e.g. 'sonnet', 'opus')
93
+ * @param {string} [opts.claudeBin] Path to the claude CLI (default: 'claude')
94
+ * @param {string} [opts.appendSystemPrompt] Extra system prompt to append
95
+ * @param {object} [opts.env] Extra environment variables
96
+ * @returns {{ run(prompt: string): Promise<{ok, text, sessionId, cost}>, reset(): void, getSessionId(): string|undefined }}
97
+ */
98
+ export function createSession(opts = {}) {
99
+ const projectDir = opts.projectDir ?? process.cwd()
100
+ const statePath = opts.statePath ?? join(projectDir, '.claude-bot', 'session.json')
101
+ const claudeOpts = {
102
+ projectDir,
103
+ permissionMode: opts.permissionMode ?? 'acceptEdits',
104
+ model: opts.model,
105
+ claudeBin: opts.claudeBin ?? 'claude',
106
+ appendSystemPrompt: opts.appendSystemPrompt,
107
+ env: opts.env ?? {},
108
+ }
109
+
110
+ let state = loadState(statePath)
111
+
112
+ return {
113
+ async run(prompt) {
114
+ const res = await _runClaude(prompt, state.sessionId, claudeOpts)
115
+ if (res.sessionId) {
116
+ state.sessionId = res.sessionId
117
+ saveState(statePath, state)
118
+ }
119
+ return res
120
+ },
121
+ reset() {
122
+ state.sessionId = undefined
123
+ saveState(statePath, state)
124
+ },
125
+ getSessionId() {
126
+ return state.sessionId
127
+ },
128
+ }
129
+ }