owlcode 0.1.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.
- package/.env.example +17 -0
- package/README.md +137 -0
- package/dist/claude/runner.d.ts +31 -0
- package/dist/claude/runner.js +160 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +139 -0
- package/dist/config.d.ts +13 -0
- package/dist/config.js +46 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +190 -0
- package/dist/session/manager.d.ts +38 -0
- package/dist/session/manager.js +156 -0
- package/dist/telegram/bot.d.ts +47 -0
- package/dist/telegram/bot.js +184 -0
- package/package.json +46 -0
package/.env.example
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# Telegram Bot Token (from @BotFather)
|
|
2
|
+
TELEGRAM_BOT_TOKEN=
|
|
3
|
+
|
|
4
|
+
# Allowed Telegram user IDs (comma-separated)
|
|
5
|
+
ALLOWED_USERS=
|
|
6
|
+
|
|
7
|
+
# Default project directory
|
|
8
|
+
DEFAULT_PROJECT_DIR=
|
|
9
|
+
|
|
10
|
+
# Claude Code CLI path (optional, defaults to 'claude')
|
|
11
|
+
CLAUDE_CLI_PATH=claude
|
|
12
|
+
|
|
13
|
+
# Session idle timeout in minutes (default: 30)
|
|
14
|
+
SESSION_TIMEOUT_MIN=30
|
|
15
|
+
|
|
16
|
+
# Log level: debug | info | warn | error
|
|
17
|
+
LOG_LEVEL=info
|
package/README.md
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# owlcode
|
|
2
|
+
|
|
3
|
+
Remote Claude Code access via Telegram. Send a message, get Claude Code's response — right from your phone.
|
|
4
|
+
|
|
5
|
+
## Prerequisites
|
|
6
|
+
|
|
7
|
+
- [Node.js](https://nodejs.org/) 20+
|
|
8
|
+
- [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code) installed and authenticated
|
|
9
|
+
|
|
10
|
+
### Claude Code authentication
|
|
11
|
+
|
|
12
|
+
Make sure Claude Code is logged in before starting the bot:
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
claude /login
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Verify it works:
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
claude --print "say hello"
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Install
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
npm install -g owlcode
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Or clone the repo:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
git clone https://github.com/cr8rcho/owlcode.git
|
|
34
|
+
cd owlcode
|
|
35
|
+
npm install
|
|
36
|
+
npm run build
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Setup
|
|
40
|
+
|
|
41
|
+
### 1. Create a Telegram bot
|
|
42
|
+
|
|
43
|
+
1. Open [@BotFather](https://t.me/botfather) on Telegram
|
|
44
|
+
2. Send `/newbot` and follow the steps
|
|
45
|
+
3. Copy the bot token
|
|
46
|
+
|
|
47
|
+
### 2. Get your Telegram user ID
|
|
48
|
+
|
|
49
|
+
1. Open [@userinfobot](https://t.me/userinfobot) on Telegram
|
|
50
|
+
2. It will show your user ID
|
|
51
|
+
|
|
52
|
+
### 3. Run the setup wizard
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
owlcode init
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
This will ask for:
|
|
59
|
+
- Telegram bot token
|
|
60
|
+
- Your Telegram user ID (only authorized users can use the bot)
|
|
61
|
+
- Default project directory
|
|
62
|
+
- Claude Code CLI path (defaults to `claude`)
|
|
63
|
+
- Session idle timeout in minutes (defaults to 30)
|
|
64
|
+
|
|
65
|
+
A `.env` file is created with your settings.
|
|
66
|
+
|
|
67
|
+
### 4. Start the bot
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
owlcode start
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Commands
|
|
74
|
+
|
|
75
|
+
| Command | Description |
|
|
76
|
+
|---------|-------------|
|
|
77
|
+
| `/ls [path]` | Browse directories with inline buttons |
|
|
78
|
+
| `/status` | Session info — current directory, Claude status, verbose level |
|
|
79
|
+
| `/session_reset` | End current Claude Code conversation and start fresh |
|
|
80
|
+
| `/verbose <0\|1\|2>` | Set output detail: 0 = result only, 1 = tools + result (default), 2 = full |
|
|
81
|
+
| `/help` | Show available commands |
|
|
82
|
+
|
|
83
|
+
Any other message is forwarded to Claude Code as a prompt. Claude Code runs in `--print` (non-interactive) mode with the current project directory as its working directory.
|
|
84
|
+
|
|
85
|
+
### Directory browsing
|
|
86
|
+
|
|
87
|
+
Use `/ls` to browse directories with tappable inline buttons. Git repos are sorted first and marked with `[git]`. Tap a folder to navigate into it, or tap "Select this directory" to set it as the current project.
|
|
88
|
+
|
|
89
|
+
You can also pass a path directly: `/ls ~/projects`
|
|
90
|
+
|
|
91
|
+
## How it works
|
|
92
|
+
|
|
93
|
+
### Session lifecycle
|
|
94
|
+
|
|
95
|
+
1. Your first message creates a session with the default project directory.
|
|
96
|
+
2. Each message spawns a Claude Code CLI process (`claude --print`) and passes your message via stdin.
|
|
97
|
+
3. After the first successful response, subsequent messages use `--continue` to maintain conversation context.
|
|
98
|
+
4. If no messages are sent for the configured timeout (default 30 min), the CLI process is released to free memory — but the conversation state is preserved. Your next message will resume with `--continue`.
|
|
99
|
+
5. `/session_reset` fully ends the conversation. The next message starts a new one.
|
|
100
|
+
|
|
101
|
+
### Session persistence
|
|
102
|
+
|
|
103
|
+
Session state (directory, verbose level) is saved to `.owlcode-sessions.json`. When the bot restarts, sessions are restored — so your project directory and settings survive reboots.
|
|
104
|
+
|
|
105
|
+
### Architecture
|
|
106
|
+
|
|
107
|
+
```
|
|
108
|
+
Telegram → TelegramBot (grammY) → SessionManager → ClaudeRunner (CLI subprocess)
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
- **TelegramBot**: Thin grammY wrapper. All Telegram-specific code isolated here for easy replacement.
|
|
112
|
+
- **SessionManager**: Per-user session with project directory tracking, idle cleanup, and disk persistence.
|
|
113
|
+
- **ClaudeRunner**: Spawns `claude --print` as subprocess. Messages passed via stdin (supports CJK). Supports text and stream-json output modes.
|
|
114
|
+
|
|
115
|
+
## CLI
|
|
116
|
+
|
|
117
|
+
| Command | Description |
|
|
118
|
+
|---------|-------------|
|
|
119
|
+
| `owlcode init` | Interactive setup wizard |
|
|
120
|
+
| `owlcode start` | Start the bot |
|
|
121
|
+
| `owlcode version` | Show version |
|
|
122
|
+
| `owlcode help` | Show help |
|
|
123
|
+
|
|
124
|
+
## Development
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
git clone https://github.com/cr8rcho/owlcode.git
|
|
128
|
+
cd owlcode
|
|
129
|
+
npm install
|
|
130
|
+
cp .env.example .env
|
|
131
|
+
# Edit .env with your settings
|
|
132
|
+
npm run dev
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
## License
|
|
136
|
+
|
|
137
|
+
MIT
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { EventEmitter } from "events";
|
|
2
|
+
export interface ClaudeRunnerOptions {
|
|
3
|
+
cliPath: string;
|
|
4
|
+
cwd: string;
|
|
5
|
+
sessionId?: string;
|
|
6
|
+
}
|
|
7
|
+
export interface ClaudeResponse {
|
|
8
|
+
text: string;
|
|
9
|
+
toolUse?: string[];
|
|
10
|
+
}
|
|
11
|
+
export declare class ClaudeRunner extends EventEmitter {
|
|
12
|
+
private options;
|
|
13
|
+
private process;
|
|
14
|
+
constructor(options: ClaudeRunnerOptions, hasExistingSession?: boolean);
|
|
15
|
+
get isRunning(): boolean;
|
|
16
|
+
get cwd(): string;
|
|
17
|
+
private hasSession;
|
|
18
|
+
private buildArgs;
|
|
19
|
+
/**
|
|
20
|
+
* Send a message to Claude Code and get the response.
|
|
21
|
+
* Uses --print flag with --continue for session continuity.
|
|
22
|
+
* Message is passed via stdin to avoid encoding issues with CJK characters.
|
|
23
|
+
*/
|
|
24
|
+
send(message: string): Promise<ClaudeResponse>;
|
|
25
|
+
/**
|
|
26
|
+
* Send a message using streaming mode with JSON output.
|
|
27
|
+
* Message is passed via stdin to avoid encoding issues.
|
|
28
|
+
*/
|
|
29
|
+
sendStreaming(message: string, onChunk: (text: string) => void): Promise<ClaudeResponse>;
|
|
30
|
+
kill(): void;
|
|
31
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude Code CLI runner.
|
|
3
|
+
* Spawns and communicates with Claude Code as a subprocess.
|
|
4
|
+
*/
|
|
5
|
+
import { spawn } from "child_process";
|
|
6
|
+
import { EventEmitter } from "events";
|
|
7
|
+
export class ClaudeRunner extends EventEmitter {
|
|
8
|
+
options;
|
|
9
|
+
process = null;
|
|
10
|
+
constructor(options, hasExistingSession = false) {
|
|
11
|
+
super();
|
|
12
|
+
this.options = options;
|
|
13
|
+
this.hasSession = hasExistingSession;
|
|
14
|
+
}
|
|
15
|
+
get isRunning() {
|
|
16
|
+
return this.process !== null && this.process.exitCode === null;
|
|
17
|
+
}
|
|
18
|
+
get cwd() {
|
|
19
|
+
return this.options.cwd;
|
|
20
|
+
}
|
|
21
|
+
hasSession = false;
|
|
22
|
+
buildArgs(outputFormat) {
|
|
23
|
+
const args = ["--print", "--output-format", outputFormat];
|
|
24
|
+
if (outputFormat === "stream-json") {
|
|
25
|
+
args.push("--verbose");
|
|
26
|
+
}
|
|
27
|
+
// Continue previous conversation if one exists
|
|
28
|
+
if (this.hasSession) {
|
|
29
|
+
args.push("--continue");
|
|
30
|
+
}
|
|
31
|
+
return args;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Send a message to Claude Code and get the response.
|
|
35
|
+
* Uses --print flag with --continue for session continuity.
|
|
36
|
+
* Message is passed via stdin to avoid encoding issues with CJK characters.
|
|
37
|
+
*/
|
|
38
|
+
async send(message) {
|
|
39
|
+
return new Promise((resolve, reject) => {
|
|
40
|
+
const args = this.buildArgs("text");
|
|
41
|
+
const proc = spawn(this.options.cliPath, args, {
|
|
42
|
+
cwd: this.options.cwd,
|
|
43
|
+
env: {
|
|
44
|
+
...process.env,
|
|
45
|
+
LANG: "en_US.UTF-8",
|
|
46
|
+
CLAUDE_CODE_DISABLE_NONINTERACTIVE_HINT: "1",
|
|
47
|
+
},
|
|
48
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
49
|
+
});
|
|
50
|
+
this.process = proc;
|
|
51
|
+
let stdout = "";
|
|
52
|
+
let stderr = "";
|
|
53
|
+
proc.stdout?.on("data", (data) => {
|
|
54
|
+
const chunk = data.toString("utf-8");
|
|
55
|
+
stdout += chunk;
|
|
56
|
+
this.emit("chunk", chunk);
|
|
57
|
+
});
|
|
58
|
+
proc.stderr?.on("data", (data) => {
|
|
59
|
+
stderr += data.toString("utf-8");
|
|
60
|
+
});
|
|
61
|
+
proc.on("close", (code) => {
|
|
62
|
+
this.process = null;
|
|
63
|
+
if (code === 0) {
|
|
64
|
+
this.hasSession = true;
|
|
65
|
+
resolve({
|
|
66
|
+
text: stdout.trim(),
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
reject(new Error(`Claude Code exited with code ${code}: ${stderr.trim()}`));
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
proc.on("error", (err) => {
|
|
74
|
+
this.process = null;
|
|
75
|
+
reject(err);
|
|
76
|
+
});
|
|
77
|
+
// Write message via stdin then close
|
|
78
|
+
proc.stdin?.write(message, "utf-8");
|
|
79
|
+
proc.stdin?.end();
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Send a message using streaming mode with JSON output.
|
|
84
|
+
* Message is passed via stdin to avoid encoding issues.
|
|
85
|
+
*/
|
|
86
|
+
async sendStreaming(message, onChunk) {
|
|
87
|
+
return new Promise((resolve, reject) => {
|
|
88
|
+
const args = this.buildArgs("stream-json");
|
|
89
|
+
const proc = spawn(this.options.cliPath, args, {
|
|
90
|
+
cwd: this.options.cwd,
|
|
91
|
+
env: {
|
|
92
|
+
...process.env,
|
|
93
|
+
LANG: "en_US.UTF-8",
|
|
94
|
+
CLAUDE_CODE_DISABLE_NONINTERACTIVE_HINT: "1",
|
|
95
|
+
},
|
|
96
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
97
|
+
});
|
|
98
|
+
this.process = proc;
|
|
99
|
+
let fullText = "";
|
|
100
|
+
let resultText = "";
|
|
101
|
+
let stderr = "";
|
|
102
|
+
const toolUse = [];
|
|
103
|
+
proc.stdout?.on("data", (data) => {
|
|
104
|
+
const lines = data.toString("utf-8").split("\n").filter(Boolean);
|
|
105
|
+
for (const line of lines) {
|
|
106
|
+
try {
|
|
107
|
+
const event = JSON.parse(line);
|
|
108
|
+
if (event.type === "assistant" && event.message?.content) {
|
|
109
|
+
for (const block of event.message.content) {
|
|
110
|
+
if (block.type === "text") {
|
|
111
|
+
fullText += block.text;
|
|
112
|
+
onChunk(block.text);
|
|
113
|
+
}
|
|
114
|
+
else if (block.type === "tool_use") {
|
|
115
|
+
toolUse.push(block.name);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
else if (event.type === "result") {
|
|
120
|
+
resultText = event.result ?? "";
|
|
121
|
+
}
|
|
122
|
+
// Ignore other event types (system, user, etc.)
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
// Non-JSON line — skip to avoid prompt echo
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
proc.stderr?.on("data", (data) => {
|
|
130
|
+
stderr += data.toString("utf-8");
|
|
131
|
+
});
|
|
132
|
+
proc.on("close", (code) => {
|
|
133
|
+
this.process = null;
|
|
134
|
+
if (code === 0) {
|
|
135
|
+
this.hasSession = true;
|
|
136
|
+
// Prefer streamed assistant text; fall back to result text only if empty
|
|
137
|
+
const finalText = fullText.trim() || resultText.trim();
|
|
138
|
+
resolve({ text: finalText, toolUse });
|
|
139
|
+
}
|
|
140
|
+
else {
|
|
141
|
+
reject(new Error(`Claude Code exited with code ${code}: ${stderr.trim()}`));
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
proc.on("error", (err) => {
|
|
145
|
+
this.process = null;
|
|
146
|
+
reject(err);
|
|
147
|
+
});
|
|
148
|
+
// Write message via stdin then close
|
|
149
|
+
proc.stdin?.write(message, "utf-8");
|
|
150
|
+
proc.stdin?.end();
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
kill() {
|
|
154
|
+
if (this.process) {
|
|
155
|
+
this.process.kill("SIGTERM");
|
|
156
|
+
this.process = null;
|
|
157
|
+
}
|
|
158
|
+
this.hasSession = false;
|
|
159
|
+
}
|
|
160
|
+
}
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { existsSync, writeFileSync, readFileSync } from "fs";
|
|
3
|
+
import { resolve, dirname } from "path";
|
|
4
|
+
import { createInterface } from "readline";
|
|
5
|
+
import { fileURLToPath } from "url";
|
|
6
|
+
import { spawn } from "child_process";
|
|
7
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
8
|
+
function ask(rl, question, defaultValue) {
|
|
9
|
+
const suffix = defaultValue ? ` (${defaultValue})` : "";
|
|
10
|
+
return new Promise((resolve) => {
|
|
11
|
+
rl.question(`${question}${suffix}: `, (answer) => {
|
|
12
|
+
resolve(answer.trim() || defaultValue || "");
|
|
13
|
+
});
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
async function init() {
|
|
17
|
+
console.log("\n🦉 owlcode setup\n");
|
|
18
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
19
|
+
console.log("1. Create a Telegram bot:");
|
|
20
|
+
console.log(" → Open @BotFather on Telegram");
|
|
21
|
+
console.log(" → Send /newbot and follow the steps");
|
|
22
|
+
console.log(" → Copy the bot token\n");
|
|
23
|
+
const botToken = await ask(rl, "Telegram bot token");
|
|
24
|
+
if (!botToken) {
|
|
25
|
+
console.error("Bot token is required.");
|
|
26
|
+
rl.close();
|
|
27
|
+
process.exit(1);
|
|
28
|
+
}
|
|
29
|
+
console.log("\n2. Get your Telegram user ID:");
|
|
30
|
+
console.log(" → Open @userinfobot on Telegram");
|
|
31
|
+
console.log(" → It will show your user ID\n");
|
|
32
|
+
const allowedUsers = await ask(rl, "Your Telegram user ID");
|
|
33
|
+
if (!allowedUsers) {
|
|
34
|
+
console.error("User ID is required.");
|
|
35
|
+
rl.close();
|
|
36
|
+
process.exit(1);
|
|
37
|
+
}
|
|
38
|
+
const defaultDir = await ask(rl, "Default project directory", process.env.HOME || "~");
|
|
39
|
+
const claudePath = await ask(rl, "Claude Code CLI path", "claude");
|
|
40
|
+
const sessionTimeout = await ask(rl, "Session idle timeout (minutes)", "30");
|
|
41
|
+
rl.close();
|
|
42
|
+
const envContent = [
|
|
43
|
+
`TELEGRAM_BOT_TOKEN=${botToken}`,
|
|
44
|
+
`ALLOWED_USERS=${allowedUsers}`,
|
|
45
|
+
`DEFAULT_PROJECT_DIR=${defaultDir}`,
|
|
46
|
+
`CLAUDE_CLI_PATH=${claudePath}`,
|
|
47
|
+
`SESSION_TIMEOUT_MIN=${sessionTimeout}`,
|
|
48
|
+
`LOG_LEVEL=info`,
|
|
49
|
+
].join("\n");
|
|
50
|
+
const envPath = resolve(process.cwd(), ".env");
|
|
51
|
+
if (existsSync(envPath)) {
|
|
52
|
+
const overwrite = await new Promise((resolve) => {
|
|
53
|
+
const rl2 = createInterface({ input: process.stdin, output: process.stdout });
|
|
54
|
+
rl2.question(".env already exists. Overwrite? (y/N): ", (answer) => {
|
|
55
|
+
rl2.close();
|
|
56
|
+
resolve(answer.trim().toLowerCase() === "y");
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
if (!overwrite) {
|
|
60
|
+
console.log("Skipped .env creation.");
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
writeFileSync(envPath, envContent + "\n");
|
|
65
|
+
console.log("\n✓ .env created");
|
|
66
|
+
console.log("\nRun 'owlcode start' to launch the bot.");
|
|
67
|
+
}
|
|
68
|
+
async function start() {
|
|
69
|
+
const envPath = resolve(process.cwd(), ".env");
|
|
70
|
+
if (!existsSync(envPath)) {
|
|
71
|
+
console.error("No .env found. Run 'owlcode init' first.");
|
|
72
|
+
process.exit(1);
|
|
73
|
+
}
|
|
74
|
+
console.log("🦉 Starting owlcode...\n");
|
|
75
|
+
// Resolve the actual index.js path
|
|
76
|
+
const indexPath = resolve(__dirname, "index.js");
|
|
77
|
+
if (!existsSync(indexPath)) {
|
|
78
|
+
console.error(`Entry point not found: ${indexPath}`);
|
|
79
|
+
process.exit(1);
|
|
80
|
+
}
|
|
81
|
+
const child = spawn("node", [indexPath], {
|
|
82
|
+
cwd: process.cwd(),
|
|
83
|
+
stdio: "inherit",
|
|
84
|
+
env: process.env,
|
|
85
|
+
});
|
|
86
|
+
child.on("exit", (code) => {
|
|
87
|
+
process.exit(code ?? 0);
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
function version() {
|
|
91
|
+
try {
|
|
92
|
+
const pkgPath = resolve(__dirname, "..", "package.json");
|
|
93
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
|
|
94
|
+
console.log(`owlcode v${pkg.version}`);
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
console.log("owlcode (unknown version)");
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
function help() {
|
|
101
|
+
console.log(`
|
|
102
|
+
🦉 owlcode — Remote Claude Code via Telegram
|
|
103
|
+
|
|
104
|
+
Commands:
|
|
105
|
+
init Interactive setup (creates .env)
|
|
106
|
+
start Start the bot
|
|
107
|
+
version Show version
|
|
108
|
+
help Show this help
|
|
109
|
+
|
|
110
|
+
Usage:
|
|
111
|
+
owlcode init
|
|
112
|
+
owlcode start
|
|
113
|
+
`);
|
|
114
|
+
}
|
|
115
|
+
// --- CLI Router ---
|
|
116
|
+
const command = process.argv[2];
|
|
117
|
+
switch (command) {
|
|
118
|
+
case "init":
|
|
119
|
+
init();
|
|
120
|
+
break;
|
|
121
|
+
case "start":
|
|
122
|
+
start();
|
|
123
|
+
break;
|
|
124
|
+
case "version":
|
|
125
|
+
case "--version":
|
|
126
|
+
case "-v":
|
|
127
|
+
version();
|
|
128
|
+
break;
|
|
129
|
+
case "help":
|
|
130
|
+
case "--help":
|
|
131
|
+
case "-h":
|
|
132
|
+
case undefined:
|
|
133
|
+
help();
|
|
134
|
+
break;
|
|
135
|
+
default:
|
|
136
|
+
console.error(`Unknown command: ${command}`);
|
|
137
|
+
help();
|
|
138
|
+
process.exit(1);
|
|
139
|
+
}
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export interface Config {
|
|
2
|
+
telegram: {
|
|
3
|
+
botToken: string;
|
|
4
|
+
allowedUsers: number[];
|
|
5
|
+
};
|
|
6
|
+
claude: {
|
|
7
|
+
cliPath: string;
|
|
8
|
+
sessionTimeoutMin: number;
|
|
9
|
+
};
|
|
10
|
+
defaultProjectDir: string;
|
|
11
|
+
logLevel: "debug" | "info" | "warn" | "error";
|
|
12
|
+
}
|
|
13
|
+
export declare function loadConfig(): Config;
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "fs";
|
|
2
|
+
import { resolve } from "path";
|
|
3
|
+
export function loadConfig() {
|
|
4
|
+
// Load .env file if exists
|
|
5
|
+
const envPath = resolve(process.cwd(), ".env");
|
|
6
|
+
if (existsSync(envPath)) {
|
|
7
|
+
const envContent = readFileSync(envPath, "utf-8");
|
|
8
|
+
for (const line of envContent.split("\n")) {
|
|
9
|
+
const trimmed = line.trim();
|
|
10
|
+
if (!trimmed || trimmed.startsWith("#"))
|
|
11
|
+
continue;
|
|
12
|
+
const eqIdx = trimmed.indexOf("=");
|
|
13
|
+
if (eqIdx === -1)
|
|
14
|
+
continue;
|
|
15
|
+
const key = trimmed.slice(0, eqIdx).trim();
|
|
16
|
+
const value = trimmed.slice(eqIdx + 1).trim();
|
|
17
|
+
if (!process.env[key]) {
|
|
18
|
+
process.env[key] = value;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
const botToken = process.env.TELEGRAM_BOT_TOKEN;
|
|
23
|
+
if (!botToken) {
|
|
24
|
+
throw new Error("TELEGRAM_BOT_TOKEN is required");
|
|
25
|
+
}
|
|
26
|
+
const allowedUsers = (process.env.ALLOWED_USERS ?? "")
|
|
27
|
+
.split(",")
|
|
28
|
+
.map((s) => s.trim())
|
|
29
|
+
.filter(Boolean)
|
|
30
|
+
.map(Number);
|
|
31
|
+
if (allowedUsers.length === 0) {
|
|
32
|
+
throw new Error("ALLOWED_USERS is required (comma-separated Telegram user IDs)");
|
|
33
|
+
}
|
|
34
|
+
return {
|
|
35
|
+
telegram: {
|
|
36
|
+
botToken,
|
|
37
|
+
allowedUsers,
|
|
38
|
+
},
|
|
39
|
+
claude: {
|
|
40
|
+
cliPath: process.env.CLAUDE_CLI_PATH ?? "claude",
|
|
41
|
+
sessionTimeoutMin: Number(process.env.SESSION_TIMEOUT_MIN ?? "30"),
|
|
42
|
+
},
|
|
43
|
+
defaultProjectDir: process.env.DEFAULT_PROJECT_DIR ?? process.env.HOME ?? "/tmp",
|
|
44
|
+
logLevel: process.env.LOG_LEVEL ?? "info",
|
|
45
|
+
};
|
|
46
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { existsSync, readdirSync } from "fs";
|
|
2
|
+
import { resolve, basename } from "path";
|
|
3
|
+
import { loadConfig } from "./config.js";
|
|
4
|
+
import { TelegramBot } from "./telegram/bot.js";
|
|
5
|
+
import { SessionManager } from "./session/manager.js";
|
|
6
|
+
const config = loadConfig();
|
|
7
|
+
const bot = new TelegramBot(config);
|
|
8
|
+
const sessions = new SessionManager(config.claude.cliPath, config.defaultProjectDir, config.claude.sessionTimeoutMin);
|
|
9
|
+
// --- Path cache for short callback data ---
|
|
10
|
+
// Telegram callback_data has a 64-byte limit, so we use short IDs
|
|
11
|
+
const pathCache = new Map();
|
|
12
|
+
let pathCounter = 0;
|
|
13
|
+
function pathToId(fullPath) {
|
|
14
|
+
// Check if already cached
|
|
15
|
+
for (const [id, path] of pathCache) {
|
|
16
|
+
if (path === fullPath)
|
|
17
|
+
return id;
|
|
18
|
+
}
|
|
19
|
+
const id = `p${pathCounter++}`;
|
|
20
|
+
pathCache.set(id, fullPath);
|
|
21
|
+
return id;
|
|
22
|
+
}
|
|
23
|
+
function idToPath(id) {
|
|
24
|
+
return pathCache.get(id);
|
|
25
|
+
}
|
|
26
|
+
// --- Helper: list directories ---
|
|
27
|
+
function listDirs(dirPath) {
|
|
28
|
+
try {
|
|
29
|
+
const entries = readdirSync(dirPath, { withFileTypes: true });
|
|
30
|
+
return entries
|
|
31
|
+
.filter((e) => e.isDirectory() && !e.name.startsWith("."))
|
|
32
|
+
.map((e) => {
|
|
33
|
+
const fullPath = resolve(dirPath, e.name);
|
|
34
|
+
const isGit = existsSync(resolve(fullPath, ".git"));
|
|
35
|
+
return { name: e.name, path: fullPath, isGit };
|
|
36
|
+
})
|
|
37
|
+
.sort((a, b) => {
|
|
38
|
+
// Git repos first, then alphabetical
|
|
39
|
+
if (a.isGit !== b.isGit)
|
|
40
|
+
return a.isGit ? -1 : 1;
|
|
41
|
+
return a.name.localeCompare(b.name);
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return [];
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
const MAX_BUTTONS = 30;
|
|
49
|
+
function buildDirButtons(dirPath) {
|
|
50
|
+
const dirs = listDirs(dirPath);
|
|
51
|
+
const buttons = [];
|
|
52
|
+
// Parent directory
|
|
53
|
+
const parent = resolve(dirPath, "..");
|
|
54
|
+
if (parent !== dirPath) {
|
|
55
|
+
buttons.push({ text: ".. (up)", data: `ls:${pathToId(parent)}` });
|
|
56
|
+
}
|
|
57
|
+
// Subdirectories (limit to avoid Telegram errors)
|
|
58
|
+
const limited = dirs.slice(0, MAX_BUTTONS);
|
|
59
|
+
for (const dir of limited) {
|
|
60
|
+
const label = dir.isGit ? `[git] ${dir.name}` : dir.name;
|
|
61
|
+
buttons.push({ text: label, data: `ls:${pathToId(dir.path)}` });
|
|
62
|
+
}
|
|
63
|
+
if (dirs.length > MAX_BUTTONS) {
|
|
64
|
+
buttons.push({ text: `... +${dirs.length - MAX_BUTTONS} more`, data: `noop:0` });
|
|
65
|
+
}
|
|
66
|
+
// Select current directory button
|
|
67
|
+
buttons.push({ text: ">> Select this directory", data: `cd:${pathToId(dirPath)}` });
|
|
68
|
+
return buttons;
|
|
69
|
+
}
|
|
70
|
+
// --- Commands ---
|
|
71
|
+
bot.onCommand("ls", async (msg) => {
|
|
72
|
+
const session = sessions.getOrCreate(msg.userId, msg.chatId);
|
|
73
|
+
const arg = msg.text.replace(/^\/ls\s*/, "").trim();
|
|
74
|
+
const targetDir = arg
|
|
75
|
+
? resolve(arg.replace("~", process.env.HOME ?? ""))
|
|
76
|
+
: session.cwd;
|
|
77
|
+
if (!existsSync(targetDir)) {
|
|
78
|
+
return `Directory not found: ${targetDir}`;
|
|
79
|
+
}
|
|
80
|
+
const buttons = buildDirButtons(targetDir);
|
|
81
|
+
if (buttons.length <= 1) {
|
|
82
|
+
return `No subdirectories in: ${targetDir}`;
|
|
83
|
+
}
|
|
84
|
+
await bot.sendWithButtons(msg.chatId, targetDir, buttons, 1);
|
|
85
|
+
});
|
|
86
|
+
bot.onCommand("status", async (msg) => {
|
|
87
|
+
const session = sessions.get(msg.userId);
|
|
88
|
+
if (!session)
|
|
89
|
+
return "No active session.";
|
|
90
|
+
return [
|
|
91
|
+
`Project: ${session.cwd}`,
|
|
92
|
+
`Claude: ${session.runner?.isRunning ? "running" : "idle"}`,
|
|
93
|
+
`Verbose: ${session.verbose}`,
|
|
94
|
+
].join("\n");
|
|
95
|
+
});
|
|
96
|
+
bot.onCommand("session_reset", async (msg) => {
|
|
97
|
+
sessions.stopSession(msg.userId);
|
|
98
|
+
return "Session reset.";
|
|
99
|
+
});
|
|
100
|
+
bot.onCommand("verbose", async (msg) => {
|
|
101
|
+
const level = parseInt(msg.text.replace(/^\/verbose\s*/, "").trim(), 10);
|
|
102
|
+
if (![0, 1, 2].includes(level)) {
|
|
103
|
+
return "Usage: /verbose <0|1|2>";
|
|
104
|
+
}
|
|
105
|
+
sessions.setVerbose(msg.userId, level);
|
|
106
|
+
return `Verbose set to ${level}.`;
|
|
107
|
+
});
|
|
108
|
+
// --- Callback handlers ---
|
|
109
|
+
// ls: browse directories
|
|
110
|
+
bot.onCallback("ls", async (query) => {
|
|
111
|
+
const pathId = query.data.replace("ls:", "");
|
|
112
|
+
const dirPath = idToPath(pathId);
|
|
113
|
+
if (!dirPath || !existsSync(dirPath))
|
|
114
|
+
return "Directory not found";
|
|
115
|
+
const buttons = buildDirButtons(dirPath);
|
|
116
|
+
await bot.editWithButtons(query.chatId, query.messageId, dirPath, buttons, 1);
|
|
117
|
+
});
|
|
118
|
+
// cd: select directory from browse
|
|
119
|
+
bot.onCallback("cd", async (query) => {
|
|
120
|
+
const pathId = query.data.replace("cd:", "");
|
|
121
|
+
const dirPath = idToPath(pathId);
|
|
122
|
+
if (!dirPath || !existsSync(dirPath))
|
|
123
|
+
return "Directory not found";
|
|
124
|
+
sessions.setCwd(query.userId, dirPath);
|
|
125
|
+
await bot.editWithButtons(query.chatId, query.messageId, `Changed to: ${dirPath}`, [], 1);
|
|
126
|
+
return `Changed to ${basename(dirPath)}`;
|
|
127
|
+
});
|
|
128
|
+
// --- Message handler: forward to Claude Code ---
|
|
129
|
+
bot.onMessage(async (msg) => {
|
|
130
|
+
const session = sessions.getOrCreate(msg.userId, msg.chatId);
|
|
131
|
+
const runner = sessions.getRunner(session);
|
|
132
|
+
// Send typing indicator
|
|
133
|
+
await bot.sendTyping(msg.chatId);
|
|
134
|
+
// Set up periodic typing indicator
|
|
135
|
+
const typingInterval = setInterval(() => {
|
|
136
|
+
bot.sendTyping(msg.chatId).catch(() => { });
|
|
137
|
+
}, 4000);
|
|
138
|
+
try {
|
|
139
|
+
if (session.verbose === 0) {
|
|
140
|
+
const response = await runner.send(msg.text);
|
|
141
|
+
sessions.setHasConversation(msg.userId, true);
|
|
142
|
+
await bot.sendMessage(msg.chatId, response.text);
|
|
143
|
+
}
|
|
144
|
+
else {
|
|
145
|
+
// Streaming mode: show progress
|
|
146
|
+
let buffer = "";
|
|
147
|
+
let lastSent = Date.now();
|
|
148
|
+
const response = await runner.sendStreaming(msg.text, (chunk) => {
|
|
149
|
+
buffer += chunk;
|
|
150
|
+
const now = Date.now();
|
|
151
|
+
if (now - lastSent > 2000 && buffer.length > 0) {
|
|
152
|
+
lastSent = now;
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
let reply = response.text;
|
|
156
|
+
if (session.verbose >= 1 && response.toolUse && response.toolUse.length > 0) {
|
|
157
|
+
const tools = response.toolUse.map((t) => `• ${t}`).join("\n");
|
|
158
|
+
reply = `${tools}\n\n${reply}`;
|
|
159
|
+
}
|
|
160
|
+
sessions.setHasConversation(msg.userId, true);
|
|
161
|
+
await bot.sendMessage(msg.chatId, reply);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
catch (err) {
|
|
165
|
+
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
166
|
+
await bot.sendMessage(msg.chatId, `Error: ${errorMsg}`);
|
|
167
|
+
}
|
|
168
|
+
finally {
|
|
169
|
+
clearInterval(typingInterval);
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
// --- Finalize: register catch-all handlers AFTER commands ---
|
|
173
|
+
bot.finalize();
|
|
174
|
+
// --- Start ---
|
|
175
|
+
sessions.startCleanup();
|
|
176
|
+
bot.start().then(() => {
|
|
177
|
+
console.log("[owlcode] Bot is running");
|
|
178
|
+
});
|
|
179
|
+
// Graceful shutdown
|
|
180
|
+
process.on("SIGINT", async () => {
|
|
181
|
+
console.log("[owlcode] Shutting down...");
|
|
182
|
+
sessions.stopCleanup();
|
|
183
|
+
await bot.stop();
|
|
184
|
+
process.exit(0);
|
|
185
|
+
});
|
|
186
|
+
process.on("SIGTERM", async () => {
|
|
187
|
+
sessions.stopCleanup();
|
|
188
|
+
await bot.stop();
|
|
189
|
+
process.exit(0);
|
|
190
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { ClaudeRunner } from "../claude/runner.js";
|
|
2
|
+
export interface Session {
|
|
3
|
+
userId: number;
|
|
4
|
+
chatId: number;
|
|
5
|
+
cwd: string;
|
|
6
|
+
runner: ClaudeRunner | null;
|
|
7
|
+
lastActive: number;
|
|
8
|
+
verbose: 0 | 1 | 2;
|
|
9
|
+
hasConversation: boolean;
|
|
10
|
+
}
|
|
11
|
+
export declare class SessionManager {
|
|
12
|
+
private sessions;
|
|
13
|
+
private cliPath;
|
|
14
|
+
private defaultCwd;
|
|
15
|
+
private timeoutMin;
|
|
16
|
+
private cleanupInterval;
|
|
17
|
+
private statePath;
|
|
18
|
+
constructor(cliPath: string, defaultCwd: string, timeoutMin: number);
|
|
19
|
+
private loadState;
|
|
20
|
+
private saveState;
|
|
21
|
+
getOrCreate(userId: number, chatId: number): Session;
|
|
22
|
+
get(userId: number): Session | undefined;
|
|
23
|
+
setCwd(userId: number, cwd: string): void;
|
|
24
|
+
getRunner(session: Session): ClaudeRunner;
|
|
25
|
+
/** Mark that a conversation exists for this session */
|
|
26
|
+
setHasConversation(userId: number, value: boolean): void;
|
|
27
|
+
setVerbose(userId: number, level: 0 | 1 | 2): void;
|
|
28
|
+
stopSession(userId: number): void;
|
|
29
|
+
/** Start periodic cleanup — only cleans up runner objects, preserves session continuity */
|
|
30
|
+
startCleanup(): void;
|
|
31
|
+
stopCleanup(): void;
|
|
32
|
+
/** Get all active sessions info */
|
|
33
|
+
listSessions(): Array<{
|
|
34
|
+
userId: number;
|
|
35
|
+
cwd: string;
|
|
36
|
+
active: boolean;
|
|
37
|
+
}>;
|
|
38
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session manager — one session per user, tracks current project directory.
|
|
3
|
+
* Persists session state (cwd, verbose) to disk for restart recovery.
|
|
4
|
+
*/
|
|
5
|
+
import { existsSync, readFileSync, writeFileSync } from "fs";
|
|
6
|
+
import { resolve } from "path";
|
|
7
|
+
import { ClaudeRunner } from "../claude/runner.js";
|
|
8
|
+
export class SessionManager {
|
|
9
|
+
sessions = new Map();
|
|
10
|
+
cliPath;
|
|
11
|
+
defaultCwd;
|
|
12
|
+
timeoutMin;
|
|
13
|
+
cleanupInterval = null;
|
|
14
|
+
statePath;
|
|
15
|
+
constructor(cliPath, defaultCwd, timeoutMin) {
|
|
16
|
+
this.cliPath = cliPath;
|
|
17
|
+
this.defaultCwd = defaultCwd;
|
|
18
|
+
this.timeoutMin = timeoutMin;
|
|
19
|
+
this.statePath = resolve(process.cwd(), ".owlcode-sessions.json");
|
|
20
|
+
this.loadState();
|
|
21
|
+
}
|
|
22
|
+
loadState() {
|
|
23
|
+
try {
|
|
24
|
+
if (existsSync(this.statePath)) {
|
|
25
|
+
const data = JSON.parse(readFileSync(this.statePath, "utf-8"));
|
|
26
|
+
for (const ps of data) {
|
|
27
|
+
if (existsSync(ps.cwd)) {
|
|
28
|
+
this.sessions.set(ps.userId, {
|
|
29
|
+
userId: ps.userId,
|
|
30
|
+
chatId: ps.chatId,
|
|
31
|
+
cwd: ps.cwd,
|
|
32
|
+
runner: null,
|
|
33
|
+
lastActive: Date.now(),
|
|
34
|
+
verbose: ps.verbose ?? 1,
|
|
35
|
+
hasConversation: true,
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
console.log(`[session] Restored ${this.sessions.size} session(s)`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
// Ignore corrupt state file
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
saveState() {
|
|
47
|
+
const data = Array.from(this.sessions.values()).map((s) => ({
|
|
48
|
+
userId: s.userId,
|
|
49
|
+
chatId: s.chatId,
|
|
50
|
+
cwd: s.cwd,
|
|
51
|
+
verbose: s.verbose,
|
|
52
|
+
}));
|
|
53
|
+
try {
|
|
54
|
+
writeFileSync(this.statePath, JSON.stringify(data, null, 2));
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
// Ignore write errors
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
getOrCreate(userId, chatId) {
|
|
61
|
+
let session = this.sessions.get(userId);
|
|
62
|
+
if (!session) {
|
|
63
|
+
session = {
|
|
64
|
+
userId,
|
|
65
|
+
chatId,
|
|
66
|
+
cwd: this.defaultCwd,
|
|
67
|
+
runner: null,
|
|
68
|
+
lastActive: Date.now(),
|
|
69
|
+
verbose: 1,
|
|
70
|
+
hasConversation: false,
|
|
71
|
+
};
|
|
72
|
+
this.sessions.set(userId, session);
|
|
73
|
+
this.saveState();
|
|
74
|
+
}
|
|
75
|
+
session.lastActive = Date.now();
|
|
76
|
+
return session;
|
|
77
|
+
}
|
|
78
|
+
get(userId) {
|
|
79
|
+
return this.sessions.get(userId);
|
|
80
|
+
}
|
|
81
|
+
setCwd(userId, cwd) {
|
|
82
|
+
const session = this.sessions.get(userId);
|
|
83
|
+
if (session) {
|
|
84
|
+
if (session.cwd !== cwd) {
|
|
85
|
+
if (session.runner) {
|
|
86
|
+
session.runner.kill();
|
|
87
|
+
session.runner = null;
|
|
88
|
+
}
|
|
89
|
+
session.hasConversation = false;
|
|
90
|
+
}
|
|
91
|
+
session.cwd = cwd;
|
|
92
|
+
this.saveState();
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
getRunner(session) {
|
|
96
|
+
if (!session.runner) {
|
|
97
|
+
session.runner = new ClaudeRunner({
|
|
98
|
+
cliPath: this.cliPath,
|
|
99
|
+
cwd: session.cwd,
|
|
100
|
+
}, session.hasConversation);
|
|
101
|
+
}
|
|
102
|
+
return session.runner;
|
|
103
|
+
}
|
|
104
|
+
/** Mark that a conversation exists for this session */
|
|
105
|
+
setHasConversation(userId, value) {
|
|
106
|
+
const session = this.sessions.get(userId);
|
|
107
|
+
if (session) {
|
|
108
|
+
session.hasConversation = value;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
setVerbose(userId, level) {
|
|
112
|
+
const session = this.sessions.get(userId);
|
|
113
|
+
if (session) {
|
|
114
|
+
session.verbose = level;
|
|
115
|
+
this.saveState();
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
stopSession(userId) {
|
|
119
|
+
const session = this.sessions.get(userId);
|
|
120
|
+
if (session) {
|
|
121
|
+
if (session.runner) {
|
|
122
|
+
session.runner.kill();
|
|
123
|
+
session.runner = null;
|
|
124
|
+
}
|
|
125
|
+
session.hasConversation = false;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
/** Start periodic cleanup — only cleans up runner objects, preserves session continuity */
|
|
129
|
+
startCleanup() {
|
|
130
|
+
this.cleanupInterval = setInterval(() => {
|
|
131
|
+
const now = Date.now();
|
|
132
|
+
const timeoutMs = this.timeoutMin * 60 * 1000;
|
|
133
|
+
for (const [userId, session] of this.sessions) {
|
|
134
|
+
if (now - session.lastActive > timeoutMs && session.runner) {
|
|
135
|
+
console.log(`[session] Releasing idle runner for user ${userId}`);
|
|
136
|
+
// Only null out the runner object, don't kill (preserves hasSession for --continue)
|
|
137
|
+
session.runner = null;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}, 60_000);
|
|
141
|
+
}
|
|
142
|
+
stopCleanup() {
|
|
143
|
+
if (this.cleanupInterval) {
|
|
144
|
+
clearInterval(this.cleanupInterval);
|
|
145
|
+
this.cleanupInterval = null;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
/** Get all active sessions info */
|
|
149
|
+
listSessions() {
|
|
150
|
+
return Array.from(this.sessions.values()).map((s) => ({
|
|
151
|
+
userId: s.userId,
|
|
152
|
+
cwd: s.cwd,
|
|
153
|
+
active: s.runner?.isRunning ?? false,
|
|
154
|
+
}));
|
|
155
|
+
}
|
|
156
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { Config } from "../config.js";
|
|
2
|
+
export interface IncomingMessage {
|
|
3
|
+
chatId: number;
|
|
4
|
+
userId: number;
|
|
5
|
+
text: string;
|
|
6
|
+
messageId: number;
|
|
7
|
+
}
|
|
8
|
+
export interface CallbackQuery {
|
|
9
|
+
chatId: number;
|
|
10
|
+
userId: number;
|
|
11
|
+
data: string;
|
|
12
|
+
messageId: number;
|
|
13
|
+
}
|
|
14
|
+
export type MessageHandler = (msg: IncomingMessage) => Promise<void>;
|
|
15
|
+
export type CallbackHandler = (query: CallbackQuery) => Promise<string | void>;
|
|
16
|
+
export declare class TelegramBot {
|
|
17
|
+
private bot;
|
|
18
|
+
private config;
|
|
19
|
+
private handler;
|
|
20
|
+
private callbackHandlers;
|
|
21
|
+
constructor(config: Config);
|
|
22
|
+
onMessage(handler: MessageHandler): void;
|
|
23
|
+
/** Register a command handler (abstracted from grammY) */
|
|
24
|
+
onCommand(command: string, handler: (msg: IncomingMessage) => Promise<string | void>): void;
|
|
25
|
+
/** Register a callback query handler by prefix */
|
|
26
|
+
onCallback(prefix: string, handler: CallbackHandler): void;
|
|
27
|
+
/**
|
|
28
|
+
* Finalize bot setup — registers catch-all handlers.
|
|
29
|
+
* Must be called AFTER all onCommand/onCallback registrations.
|
|
30
|
+
*/
|
|
31
|
+
finalize(): void;
|
|
32
|
+
sendMessage(chatId: number, text: string): Promise<void>;
|
|
33
|
+
/** Send a message with inline keyboard buttons */
|
|
34
|
+
sendWithButtons(chatId: number, text: string, buttons: Array<{
|
|
35
|
+
text: string;
|
|
36
|
+
data: string;
|
|
37
|
+
}>, columns?: number): Promise<void>;
|
|
38
|
+
/** Edit an existing message with new buttons */
|
|
39
|
+
editWithButtons(chatId: number, messageId: number, text: string, buttons: Array<{
|
|
40
|
+
text: string;
|
|
41
|
+
data: string;
|
|
42
|
+
}>, columns?: number): Promise<void>;
|
|
43
|
+
sendTyping(chatId: number): Promise<void>;
|
|
44
|
+
private splitMessage;
|
|
45
|
+
start(): Promise<void>;
|
|
46
|
+
stop(): Promise<void>;
|
|
47
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Telegram layer — thin wrapper around grammY.
|
|
3
|
+
* All grammY-specific code stays in this directory.
|
|
4
|
+
* The rest of the app communicates through MessageHandler interface.
|
|
5
|
+
*/
|
|
6
|
+
import { Bot, InlineKeyboard } from "grammy";
|
|
7
|
+
export class TelegramBot {
|
|
8
|
+
bot;
|
|
9
|
+
config;
|
|
10
|
+
handler = null;
|
|
11
|
+
callbackHandlers = new Map();
|
|
12
|
+
constructor(config) {
|
|
13
|
+
this.config = config;
|
|
14
|
+
this.bot = new Bot(config.telegram.botToken);
|
|
15
|
+
// Auth middleware — must be first
|
|
16
|
+
this.bot.use(async (ctx, next) => {
|
|
17
|
+
const userId = ctx.from?.id;
|
|
18
|
+
if (!userId || !this.config.telegram.allowedUsers.includes(userId)) {
|
|
19
|
+
await ctx.reply("Unauthorized.");
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
await next();
|
|
23
|
+
});
|
|
24
|
+
// Built-in commands
|
|
25
|
+
this.bot.command("start", (ctx) => ctx.reply("owlcode ready. Send a message to start coding."));
|
|
26
|
+
this.bot.command("help", (ctx) => ctx.reply([
|
|
27
|
+
"/projects — List registered projects",
|
|
28
|
+
"/ls — Browse directories (with buttons)",
|
|
29
|
+
"/status — Current session status",
|
|
30
|
+
"/session_reset — Reset Claude Code session",
|
|
31
|
+
"/verbose <0|1|2> — Output detail level",
|
|
32
|
+
"",
|
|
33
|
+
"Any other message is sent to Claude Code.",
|
|
34
|
+
].join("\n")));
|
|
35
|
+
}
|
|
36
|
+
onMessage(handler) {
|
|
37
|
+
this.handler = handler;
|
|
38
|
+
}
|
|
39
|
+
/** Register a command handler (abstracted from grammY) */
|
|
40
|
+
onCommand(command, handler) {
|
|
41
|
+
this.bot.command(command, async (ctx) => {
|
|
42
|
+
try {
|
|
43
|
+
const result = await handler({
|
|
44
|
+
chatId: ctx.chat.id,
|
|
45
|
+
userId: ctx.from.id,
|
|
46
|
+
text: ctx.message?.text ?? "",
|
|
47
|
+
messageId: ctx.message?.message_id ?? 0,
|
|
48
|
+
});
|
|
49
|
+
if (result) {
|
|
50
|
+
await ctx.reply(result);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
catch (err) {
|
|
54
|
+
console.error(`[command:${command}]`, err);
|
|
55
|
+
await ctx.reply(`Error: ${err instanceof Error ? err.message : String(err)}`);
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
/** Register a callback query handler by prefix */
|
|
60
|
+
onCallback(prefix, handler) {
|
|
61
|
+
this.callbackHandlers.set(prefix, handler);
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Finalize bot setup — registers catch-all handlers.
|
|
65
|
+
* Must be called AFTER all onCommand/onCallback registrations.
|
|
66
|
+
*/
|
|
67
|
+
finalize() {
|
|
68
|
+
// Callback query handler (inline buttons)
|
|
69
|
+
this.bot.on("callback_query:data", async (ctx) => {
|
|
70
|
+
const data = ctx.callbackQuery.data;
|
|
71
|
+
const prefix = data.split(":")[0];
|
|
72
|
+
const handler = this.callbackHandlers.get(prefix);
|
|
73
|
+
if (handler) {
|
|
74
|
+
try {
|
|
75
|
+
const result = await handler({
|
|
76
|
+
chatId: ctx.chat?.id ?? 0,
|
|
77
|
+
userId: ctx.from.id,
|
|
78
|
+
data,
|
|
79
|
+
messageId: ctx.callbackQuery.message?.message_id ?? 0,
|
|
80
|
+
});
|
|
81
|
+
if (result) {
|
|
82
|
+
await ctx.answerCallbackQuery({ text: result });
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
await ctx.answerCallbackQuery();
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
catch (err) {
|
|
89
|
+
console.error(`[callback:${prefix}]`, err);
|
|
90
|
+
await ctx.answerCallbackQuery({ text: "Error occurred" });
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
else {
|
|
94
|
+
await ctx.answerCallbackQuery();
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
// Catch-all: forward non-command text messages to handler
|
|
98
|
+
this.bot.on("message:text", async (ctx) => {
|
|
99
|
+
if (!this.handler)
|
|
100
|
+
return;
|
|
101
|
+
const text = ctx.message.text;
|
|
102
|
+
if (text.startsWith("/"))
|
|
103
|
+
return;
|
|
104
|
+
await this.handler({
|
|
105
|
+
chatId: ctx.chat.id,
|
|
106
|
+
userId: ctx.from.id,
|
|
107
|
+
text,
|
|
108
|
+
messageId: ctx.message.message_id,
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
async sendMessage(chatId, text) {
|
|
113
|
+
const chunks = this.splitMessage(text);
|
|
114
|
+
for (const chunk of chunks) {
|
|
115
|
+
await this.bot.api.sendMessage(chatId, chunk, {
|
|
116
|
+
parse_mode: "Markdown",
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
/** Send a message with inline keyboard buttons */
|
|
121
|
+
async sendWithButtons(chatId, text, buttons, columns = 2) {
|
|
122
|
+
const keyboard = new InlineKeyboard();
|
|
123
|
+
buttons.forEach((btn, i) => {
|
|
124
|
+
keyboard.text(btn.text, btn.data);
|
|
125
|
+
if ((i + 1) % columns === 0)
|
|
126
|
+
keyboard.row();
|
|
127
|
+
});
|
|
128
|
+
await this.bot.api.sendMessage(chatId, text, {
|
|
129
|
+
reply_markup: keyboard,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
/** Edit an existing message with new buttons */
|
|
133
|
+
async editWithButtons(chatId, messageId, text, buttons, columns = 2) {
|
|
134
|
+
const keyboard = new InlineKeyboard();
|
|
135
|
+
buttons.forEach((btn, i) => {
|
|
136
|
+
keyboard.text(btn.text, btn.data);
|
|
137
|
+
if ((i + 1) % columns === 0)
|
|
138
|
+
keyboard.row();
|
|
139
|
+
});
|
|
140
|
+
await this.bot.api.editMessageText(chatId, messageId, text, {
|
|
141
|
+
reply_markup: keyboard,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
async sendTyping(chatId) {
|
|
145
|
+
await this.bot.api.sendChatAction(chatId, "typing");
|
|
146
|
+
}
|
|
147
|
+
splitMessage(text, maxLen = 4096) {
|
|
148
|
+
if (text.length <= maxLen)
|
|
149
|
+
return [text];
|
|
150
|
+
const chunks = [];
|
|
151
|
+
let remaining = text;
|
|
152
|
+
while (remaining.length > 0) {
|
|
153
|
+
if (remaining.length <= maxLen) {
|
|
154
|
+
chunks.push(remaining);
|
|
155
|
+
break;
|
|
156
|
+
}
|
|
157
|
+
let splitIdx = remaining.lastIndexOf("\n", maxLen);
|
|
158
|
+
if (splitIdx === -1 || splitIdx < maxLen / 2) {
|
|
159
|
+
splitIdx = maxLen;
|
|
160
|
+
}
|
|
161
|
+
chunks.push(remaining.slice(0, splitIdx));
|
|
162
|
+
remaining = remaining.slice(splitIdx);
|
|
163
|
+
}
|
|
164
|
+
return chunks;
|
|
165
|
+
}
|
|
166
|
+
async start() {
|
|
167
|
+
console.log("[telegram] Starting bot...");
|
|
168
|
+
// Register command menu (shows when user types /)
|
|
169
|
+
await this.bot.api.setMyCommands([
|
|
170
|
+
{ command: "ls", description: "Browse directories" },
|
|
171
|
+
{ command: "status", description: "Session status" },
|
|
172
|
+
{ command: "session_reset", description: "Reset Claude Code session" },
|
|
173
|
+
{ command: "verbose", description: "Output detail level (0/1/2)" },
|
|
174
|
+
{ command: "help", description: "Show available commands" },
|
|
175
|
+
]);
|
|
176
|
+
this.bot.catch((err) => {
|
|
177
|
+
console.error("[bot] Error:", err.message ?? err);
|
|
178
|
+
});
|
|
179
|
+
this.bot.start();
|
|
180
|
+
}
|
|
181
|
+
async stop() {
|
|
182
|
+
await this.bot.stop();
|
|
183
|
+
}
|
|
184
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "owlcode",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Telegram bot for remote Claude Code access — your owl delivers code commands and brings back results",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"owlcode": "dist/cli.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"dist",
|
|
12
|
+
"README.md",
|
|
13
|
+
".env.example"
|
|
14
|
+
],
|
|
15
|
+
"scripts": {
|
|
16
|
+
"build": "tsc",
|
|
17
|
+
"start": "node dist/index.js",
|
|
18
|
+
"dev": "tsx watch src/index.ts",
|
|
19
|
+
"prepublishOnly": "npm run build"
|
|
20
|
+
},
|
|
21
|
+
"keywords": [
|
|
22
|
+
"telegram",
|
|
23
|
+
"claude-code",
|
|
24
|
+
"ai",
|
|
25
|
+
"coding-agent",
|
|
26
|
+
"cli"
|
|
27
|
+
],
|
|
28
|
+
"author": "John Cho <cr8rcho@users.noreply.github.com>",
|
|
29
|
+
"license": "MIT",
|
|
30
|
+
"repository": {
|
|
31
|
+
"type": "git",
|
|
32
|
+
"url": "https://github.com/cr8rcho/owlcode.git"
|
|
33
|
+
},
|
|
34
|
+
"homepage": "https://github.com/cr8rcho/owlcode",
|
|
35
|
+
"engines": {
|
|
36
|
+
"node": ">=20.0.0"
|
|
37
|
+
},
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"grammy": "^1.40.0"
|
|
40
|
+
},
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"@types/node": "^25.3.0",
|
|
43
|
+
"tsx": "^4.21.0",
|
|
44
|
+
"typescript": "^5.9.3"
|
|
45
|
+
}
|
|
46
|
+
}
|