@alexnodeland/claude-telegram 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/CHANGELOG.md +26 -0
- package/LICENSE +21 -0
- package/README.md +191 -0
- package/package.json +67 -0
- package/src/access.ts +73 -0
- package/src/commands.ts +161 -0
- package/src/config.ts +40 -0
- package/src/html.ts +25 -0
- package/src/index.ts +514 -0
- package/src/orchestrator.ts +1618 -0
- package/src/permission-relay.ts +106 -0
- package/src/relay-server.ts +119 -0
- package/src/sessions.ts +110 -0
- package/src/streaming.ts +255 -0
- package/src/telegram.ts +166 -0
- package/src/types.ts +214 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [0.1.0] - 2025-03-22
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- **Orchestrator mode** — standalone process that spawns and manages Claude CLI subprocesses
|
|
13
|
+
- **Channel mode** — MCP channel plugin that attaches Telegram to a running Claude Code session
|
|
14
|
+
- **Real-time streaming** — tool calls, text, and errors stream as they happen with live status bar
|
|
15
|
+
- **Session management** — create, resume, list, and stop sessions across multiple projects
|
|
16
|
+
- **Navigable directory browser** — drill into folders, bookmark shortcuts, pick project roots
|
|
17
|
+
- **Permission relay** — approve/deny/always prompts forwarded to Telegram with granular options
|
|
18
|
+
- **Slash command pass-through** — `/cc commit`, `/cc review-pr`, etc.
|
|
19
|
+
- **Mode switching** — normal, plan, and auto-accept permission modes
|
|
20
|
+
- **Model switching** — sonnet, opus, haiku via `/model`
|
|
21
|
+
- **Pairing-code access control** — 6-character codes, 10-minute expiry, persistent allowlist
|
|
22
|
+
- **Cost tracking** — per-session token usage and cost display
|
|
23
|
+
- **HTML formatting** — all output uses Telegram HTML parse mode for reliable rendering
|
|
24
|
+
- **Zero Telegram SDK** — all API calls use native `fetch`
|
|
25
|
+
|
|
26
|
+
[0.1.0]: https://github.com/alexnodeland/claude-telegram/releases/tag/v0.1.0
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Alexander Nodeland
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
|
|
3
|
+
# claude-telegram
|
|
4
|
+
|
|
5
|
+
**Control Claude Code from Telegram — run tasks, review diffs, and ship code from your phone.**
|
|
6
|
+
|
|
7
|
+
[](https://github.com/alexnodeland/claude-telegram/releases)
|
|
8
|
+
[](https://github.com/alexnodeland/claude-telegram/actions/workflows/ci.yml)
|
|
9
|
+
[](https://opensource.org/licenses/MIT)
|
|
10
|
+
[](https://bun.sh)
|
|
11
|
+
[](https://docs.anthropic.com/en/docs/claude-code)
|
|
12
|
+
[](https://modelcontextprotocol.io)
|
|
13
|
+
|
|
14
|
+
<br />
|
|
15
|
+
|
|
16
|
+
<img src="docs/images/working.jpg" width="340" alt="Real-time streaming in Telegram" />
|
|
17
|
+
|
|
18
|
+
<br />
|
|
19
|
+
|
|
20
|
+
_Tool calls stream in real time with a live status bar and cost tracking._
|
|
21
|
+
|
|
22
|
+
</div>
|
|
23
|
+
|
|
24
|
+
<br />
|
|
25
|
+
|
|
26
|
+
A local bridge between Telegram and [Claude Code](https://docs.anthropic.com/en/docs/claude-code). Send a message from your phone, Claude reads your codebase, edits files, runs commands, and replies — everything stays on your machine.
|
|
27
|
+
|
|
28
|
+
```mermaid
|
|
29
|
+
---
|
|
30
|
+
config:
|
|
31
|
+
layout: elk
|
|
32
|
+
---
|
|
33
|
+
graph LR
|
|
34
|
+
A["You\n(Telegram)"] -->|message| B["claude-telegram\n(runs locally)"]
|
|
35
|
+
B -->|polls & streams| A
|
|
36
|
+
B -->|spawns subprocess| C["Claude Code\n(your machine)"]
|
|
37
|
+
C -->|NDJSON stream| B
|
|
38
|
+
|
|
39
|
+
style A fill:#26A5E4,color:#fff,stroke:none
|
|
40
|
+
style B fill:#1a1a2e,color:#fff,stroke:#444
|
|
41
|
+
style C fill:#cc785c,color:#fff,stroke:none
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Why
|
|
45
|
+
|
|
46
|
+
You're away from your desk but need to:
|
|
47
|
+
|
|
48
|
+
- Fix a broken CI pipeline before standup
|
|
49
|
+
- Ask a question about a codebase you don't have open
|
|
50
|
+
- Kick off a refactor and watch it happen
|
|
51
|
+
- Review and commit code on the go
|
|
52
|
+
|
|
53
|
+
Claude Telegram gives you full Claude Code access from any Telegram client — phone, tablet, desktop. No SSH, no cloud relay, no exposed ports. It runs on your machine and talks to Telegram's Bot API.
|
|
54
|
+
|
|
55
|
+
## Quick start
|
|
56
|
+
|
|
57
|
+
**1. Create a Telegram bot** — message [@BotFather](https://t.me/BotFather), send `/newbot`, copy the token.
|
|
58
|
+
|
|
59
|
+
**2. Install and run:**
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
git clone https://github.com/alexnodeland/claude-telegram.git
|
|
63
|
+
cd claude-telegram
|
|
64
|
+
bun install
|
|
65
|
+
export TELEGRAM_BOT_TOKEN=your_token_here
|
|
66
|
+
bun run start:orchestrator
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
**3. Pair your Telegram account** — send `/start` to your bot, then enter the pairing code shown in your terminal.
|
|
70
|
+
|
|
71
|
+
> [!TIP]
|
|
72
|
+
> The **orchestrator mode** above is standalone and manages its own Claude sessions. There's also a [channel mode](docs/channel-mode.md) that attaches Telegram to an existing Claude Code session as an MCP plugin.
|
|
73
|
+
|
|
74
|
+
## Features
|
|
75
|
+
|
|
76
|
+
### Session management
|
|
77
|
+
Start sessions in any project directory with a navigable file browser. Resume previous sessions by title. Run multiple sessions across different projects.
|
|
78
|
+
|
|
79
|
+
### Real-time streaming
|
|
80
|
+
Tool calls, text output, and errors stream as separate messages with a live status bar showing the current step, tool count, and running cost.
|
|
81
|
+
|
|
82
|
+
### Permission control
|
|
83
|
+
Permission prompts are forwarded to Telegram with granular options — approve once, for the session, or always for the project. Switch between normal, plan, and auto-accept modes.
|
|
84
|
+
|
|
85
|
+
### Slash command pass-through
|
|
86
|
+
Run Claude Code commands directly: `/cc commit`, `/cc review-pr 123`, `/cc diff`. Or tap through an interactive menu.
|
|
87
|
+
|
|
88
|
+
## Commands
|
|
89
|
+
|
|
90
|
+
| Command | What it does |
|
|
91
|
+
|---|---|
|
|
92
|
+
| `/new` | Start a session — shows a directory browser to pick your project |
|
|
93
|
+
| `/resume` | Resume a previous session by title |
|
|
94
|
+
| `/sessions` | List all sessions with tap-to-resume buttons |
|
|
95
|
+
| `/cc` | Claude Code slash commands — menu or `/cc commit` directly |
|
|
96
|
+
| `/mode` | Switch between normal / plan / auto-accept |
|
|
97
|
+
| `/model` | Switch between sonnet / opus / haiku |
|
|
98
|
+
| `/stop` | Stop current task or end session |
|
|
99
|
+
| `/dirs` | Browse bookmarked and recent directories |
|
|
100
|
+
| `/bookmark` | Save a directory shortcut: `/bookmark /path --name alias` |
|
|
101
|
+
| `/cost` | Show session cost |
|
|
102
|
+
| `/status` | Full session info |
|
|
103
|
+
| `/help` | Show all commands |
|
|
104
|
+
|
|
105
|
+
Anything that isn't a command is sent as a prompt to Claude.
|
|
106
|
+
|
|
107
|
+
## Screenshots
|
|
108
|
+
|
|
109
|
+
<table>
|
|
110
|
+
<tr>
|
|
111
|
+
<td width="50%">
|
|
112
|
+
|
|
113
|
+
**Permission prompts** — approve once, for the session, or always for the project.
|
|
114
|
+
|
|
115
|
+
</td>
|
|
116
|
+
<td width="50%">
|
|
117
|
+
|
|
118
|
+
**Directory browser** — navigate folders, bookmark shortcuts, tap to start.
|
|
119
|
+
|
|
120
|
+
</td>
|
|
121
|
+
</tr>
|
|
122
|
+
<tr>
|
|
123
|
+
<td>
|
|
124
|
+
|
|
125
|
+
<img src="docs/images/permissions.jpg" width="300" alt="Permission prompt with granular options" />
|
|
126
|
+
|
|
127
|
+
</td>
|
|
128
|
+
<td>
|
|
129
|
+
|
|
130
|
+
<img src="docs/images/new.jpg" width="300" alt="Navigable directory browser" />
|
|
131
|
+
|
|
132
|
+
</td>
|
|
133
|
+
</tr>
|
|
134
|
+
<tr>
|
|
135
|
+
<td>
|
|
136
|
+
|
|
137
|
+
**`/cc` command menu** — run slash commands and switch modes from the chat.
|
|
138
|
+
|
|
139
|
+
</td>
|
|
140
|
+
<td>
|
|
141
|
+
|
|
142
|
+
**Session list** — auto-generated titles, tap to resume.
|
|
143
|
+
|
|
144
|
+
</td>
|
|
145
|
+
</tr>
|
|
146
|
+
<tr>
|
|
147
|
+
<td>
|
|
148
|
+
|
|
149
|
+
<img src="docs/images/cc.jpg" width="300" alt="/cc command menu and mode switching" />
|
|
150
|
+
|
|
151
|
+
</td>
|
|
152
|
+
<td>
|
|
153
|
+
|
|
154
|
+
<img src="docs/images/sessions.jpg" width="300" alt="Session list with titles and resume buttons" />
|
|
155
|
+
|
|
156
|
+
</td>
|
|
157
|
+
</tr>
|
|
158
|
+
</table>
|
|
159
|
+
|
|
160
|
+
## Security
|
|
161
|
+
|
|
162
|
+
- **Runs locally** — no cloud relay, no data leaves your machine, no inbound ports
|
|
163
|
+
- **Pairing codes** — 6-character, 10-minute expiry, one-time use
|
|
164
|
+
- **Allowlist** — only paired Telegram users can interact
|
|
165
|
+
- **Permission relay** — HTTP server binds to `127.0.0.1` only
|
|
166
|
+
- **Zero Telegram SDK** — native `fetch` only, minimal attack surface
|
|
167
|
+
|
|
168
|
+
## Documentation
|
|
169
|
+
|
|
170
|
+
| Doc | Contents |
|
|
171
|
+
|---|---|
|
|
172
|
+
| [Orchestrator Mode](docs/orchestrator-mode.md) | Setup, commands, permissions, streaming, sessions, env vars |
|
|
173
|
+
| [Channel Mode](docs/channel-mode.md) | MCP channel plugin setup, pairing, access commands, tools |
|
|
174
|
+
| [Architecture](docs/architecture.md) | System design, module map, security model, data flow |
|
|
175
|
+
| [Changelog](CHANGELOG.md) | Release history |
|
|
176
|
+
|
|
177
|
+
## Development
|
|
178
|
+
|
|
179
|
+
```bash
|
|
180
|
+
just setup # Install deps + configure git hooks
|
|
181
|
+
just dev-orchestrator # Watch mode
|
|
182
|
+
just ci # Typecheck + lint + test (112 tests)
|
|
183
|
+
just fix # Auto-fix lint/format
|
|
184
|
+
just release 1.1.0 # Tag a release
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
See [CLAUDE.md](CLAUDE.md) for architecture details and contributor guidelines.
|
|
188
|
+
|
|
189
|
+
## License
|
|
190
|
+
|
|
191
|
+
[MIT](LICENSE)
|
package/package.json
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@alexnodeland/claude-telegram",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Claude Code Channel plugin + standalone orchestrator bridging Telegram to Claude Code sessions",
|
|
5
|
+
"module": "src/index.ts",
|
|
6
|
+
"main": "src/index.ts",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"bin": {
|
|
9
|
+
"claude-telegram": "./src/index.ts",
|
|
10
|
+
"claude-telegram-orchestrator": "./src/orchestrator.ts"
|
|
11
|
+
},
|
|
12
|
+
"scripts": {
|
|
13
|
+
"start": "bun run src/index.ts",
|
|
14
|
+
"start:orchestrator": "bun run src/orchestrator.ts",
|
|
15
|
+
"dev": "bun --watch run src/index.ts",
|
|
16
|
+
"dev:orchestrator": "bun --watch run src/orchestrator.ts",
|
|
17
|
+
"typecheck": "tsc --noEmit",
|
|
18
|
+
"lint": "biome lint src/",
|
|
19
|
+
"format": "biome format --write src/",
|
|
20
|
+
"format:check": "biome format src/",
|
|
21
|
+
"check": "biome check src/",
|
|
22
|
+
"check:fix": "biome check --write src/",
|
|
23
|
+
"test": "bun test",
|
|
24
|
+
"test:watch": "bun test --watch",
|
|
25
|
+
"prepare": "git config core.hooksPath .githooks"
|
|
26
|
+
},
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"@modelcontextprotocol/sdk": "^1.12.0",
|
|
29
|
+
"zod": "^3.23.8"
|
|
30
|
+
},
|
|
31
|
+
"devDependencies": {
|
|
32
|
+
"@biomejs/biome": "^2.4.8",
|
|
33
|
+
"@types/bun": "latest",
|
|
34
|
+
"bun-types": "^1.3.11",
|
|
35
|
+
"typescript": "^5.4.0"
|
|
36
|
+
},
|
|
37
|
+
"files": [
|
|
38
|
+
"src/",
|
|
39
|
+
"LICENSE",
|
|
40
|
+
"README.md",
|
|
41
|
+
"CHANGELOG.md"
|
|
42
|
+
],
|
|
43
|
+
"engines": {
|
|
44
|
+
"bun": ">=1.1.0"
|
|
45
|
+
},
|
|
46
|
+
"keywords": [
|
|
47
|
+
"mcp",
|
|
48
|
+
"model-context-protocol",
|
|
49
|
+
"claude",
|
|
50
|
+
"claude-code",
|
|
51
|
+
"telegram",
|
|
52
|
+
"telegram-bot",
|
|
53
|
+
"channel",
|
|
54
|
+
"orchestrator",
|
|
55
|
+
"ai-agent",
|
|
56
|
+
"cli"
|
|
57
|
+
],
|
|
58
|
+
"license": "MIT",
|
|
59
|
+
"repository": {
|
|
60
|
+
"type": "git",
|
|
61
|
+
"url": "git+https://github.com/alexnodeland/claude-telegram.git"
|
|
62
|
+
},
|
|
63
|
+
"homepage": "https://github.com/alexnodeland/claude-telegram#readme",
|
|
64
|
+
"bugs": {
|
|
65
|
+
"url": "https://github.com/alexnodeland/claude-telegram/issues"
|
|
66
|
+
}
|
|
67
|
+
}
|
package/src/access.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
import { PAIRING_CODE_LENGTH } from "./config.js";
|
|
4
|
+
import type { AccessState, PendingPairing } from "./types.js";
|
|
5
|
+
|
|
6
|
+
// ─── Persistence ──────────────────────────────────────────────────────────────
|
|
7
|
+
|
|
8
|
+
export async function loadAccessState(allowlistPath: string): Promise<AccessState> {
|
|
9
|
+
try {
|
|
10
|
+
const raw = JSON.parse(await readFile(allowlistPath, "utf8")) as {
|
|
11
|
+
policy: AccessState["policy"];
|
|
12
|
+
allowlist: number[];
|
|
13
|
+
};
|
|
14
|
+
return { policy: raw.policy ?? "pairing", allowlist: raw.allowlist ?? [], pendingCodes: new Map() };
|
|
15
|
+
} catch {
|
|
16
|
+
return { policy: "pairing", allowlist: [], pendingCodes: new Map() };
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function saveAccessState(allowlistPath: string, state: AccessState): Promise<void> {
|
|
21
|
+
await mkdir(dirname(allowlistPath), { recursive: true });
|
|
22
|
+
await writeFile(allowlistPath, JSON.stringify({ policy: state.policy, allowlist: state.allowlist }, null, 2));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// ─── Pairing Codes ────────────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
const CHARSET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
|
28
|
+
|
|
29
|
+
export function generatePairingCode(): string {
|
|
30
|
+
const buf = new Uint8Array(PAIRING_CODE_LENGTH);
|
|
31
|
+
crypto.getRandomValues(buf);
|
|
32
|
+
return [...buf].map((b) => CHARSET[b % CHARSET.length]).join("");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function issuePairingCode(
|
|
36
|
+
state: AccessState,
|
|
37
|
+
pairing: { userId: number; chatId: number; username?: string; firstName: string },
|
|
38
|
+
ttlMs: number,
|
|
39
|
+
): string {
|
|
40
|
+
const now = Date.now();
|
|
41
|
+
for (const [k, v] of state.pendingCodes) {
|
|
42
|
+
if (v.expiresAt < now) state.pendingCodes.delete(k);
|
|
43
|
+
}
|
|
44
|
+
const code = generatePairingCode();
|
|
45
|
+
state.pendingCodes.set(code, { ...pairing, expiresAt: now + ttlMs });
|
|
46
|
+
return code;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function consumePairingCode(state: AccessState, code: string): PendingPairing | null {
|
|
50
|
+
const key = code.toUpperCase().trim();
|
|
51
|
+
const entry = state.pendingCodes.get(key);
|
|
52
|
+
if (!entry || entry.expiresAt < Date.now()) {
|
|
53
|
+
state.pendingCodes.delete(key);
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
state.pendingCodes.delete(key);
|
|
57
|
+
return entry;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ─── Gate ─────────────────────────────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
export function isAllowed(state: AccessState, userId: number): boolean {
|
|
63
|
+
if (state.policy === "open") return true;
|
|
64
|
+
return state.allowlist.includes(userId);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function addToAllowlist(state: AccessState, userId: number): void {
|
|
68
|
+
if (!state.allowlist.includes(userId)) state.allowlist.push(userId);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function removeFromAllowlist(state: AccessState, userId: number): void {
|
|
72
|
+
state.allowlist = state.allowlist.filter((id) => id !== userId);
|
|
73
|
+
}
|
package/src/commands.ts
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse Telegram message text into a structured command.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { PermissionMode } from "./types.js";
|
|
6
|
+
|
|
7
|
+
/** Known bot commands — used to distinguish unknown /commands from prompts. */
|
|
8
|
+
const KNOWN_COMMANDS = new Set([
|
|
9
|
+
"new",
|
|
10
|
+
"resume",
|
|
11
|
+
"sessions",
|
|
12
|
+
"list",
|
|
13
|
+
"stop",
|
|
14
|
+
"end",
|
|
15
|
+
"compact",
|
|
16
|
+
"model",
|
|
17
|
+
"mode",
|
|
18
|
+
"cost",
|
|
19
|
+
"status",
|
|
20
|
+
"help",
|
|
21
|
+
"approve",
|
|
22
|
+
"cc",
|
|
23
|
+
"start",
|
|
24
|
+
"pair",
|
|
25
|
+
"dirs",
|
|
26
|
+
"bookmark",
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
export type Command =
|
|
30
|
+
// Session management
|
|
31
|
+
| { type: "new"; cwd?: string; name?: string }
|
|
32
|
+
| { type: "resume"; target?: string }
|
|
33
|
+
| { type: "sessions" }
|
|
34
|
+
| { type: "stop" }
|
|
35
|
+
// Claude control
|
|
36
|
+
| { type: "compact" }
|
|
37
|
+
| { type: "model"; model?: string }
|
|
38
|
+
| { type: "mode"; mode?: PermissionMode }
|
|
39
|
+
| { type: "cost" }
|
|
40
|
+
| { type: "status" }
|
|
41
|
+
// Claude Code slash command pass-through
|
|
42
|
+
| { type: "cc"; slashCommand: string; args: string }
|
|
43
|
+
| { type: "cc_menu" } // /cc alone — show command picker
|
|
44
|
+
// Directory bookmarks
|
|
45
|
+
| { type: "dirs" }
|
|
46
|
+
| { type: "bookmark"; path?: string; name?: string }
|
|
47
|
+
// Admin
|
|
48
|
+
| { type: "help" }
|
|
49
|
+
| { type: "approve"; code: string }
|
|
50
|
+
// Unknown /command (not a known bot command)
|
|
51
|
+
| { type: "unknown_command"; text: string }
|
|
52
|
+
// Pass-through
|
|
53
|
+
| { type: "prompt"; text: string };
|
|
54
|
+
|
|
55
|
+
export function parseCommand(text: string): Command {
|
|
56
|
+
const trimmed = text.trim();
|
|
57
|
+
|
|
58
|
+
if (trimmed === "/new" || trimmed.startsWith("/new ")) {
|
|
59
|
+
const args = trimmed.slice(4).trim();
|
|
60
|
+
if (!args) return { type: "new" };
|
|
61
|
+
|
|
62
|
+
const nameMatch = args.match(/--name\s+(\S+)/);
|
|
63
|
+
const name = nameMatch?.[1];
|
|
64
|
+
const cwd = args.replace(/--name\s+\S+/, "").trim() || undefined;
|
|
65
|
+
return { type: "new", cwd, name };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (trimmed === "/resume" || trimmed.startsWith("/resume ")) {
|
|
69
|
+
const target = trimmed.slice(7).trim() || undefined;
|
|
70
|
+
return { type: "resume", target };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (trimmed === "/sessions" || trimmed === "/list") {
|
|
74
|
+
return { type: "sessions" };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (trimmed === "/stop" || trimmed === "/end") {
|
|
78
|
+
return { type: "stop" };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (trimmed === "/compact") {
|
|
82
|
+
return { type: "compact" };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (trimmed === "/model" || trimmed.startsWith("/model ")) {
|
|
86
|
+
const model = trimmed.slice(6).trim() || undefined;
|
|
87
|
+
return { type: "model", model };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// /mode [normal|plan|auto-accept]
|
|
91
|
+
if (trimmed === "/mode" || trimmed.startsWith("/mode ")) {
|
|
92
|
+
const modeArg = trimmed.slice(5).trim() || undefined;
|
|
93
|
+
if (!modeArg) return { type: "mode" };
|
|
94
|
+
const valid: PermissionMode[] = ["normal", "plan", "auto-accept"];
|
|
95
|
+
if (valid.includes(modeArg as PermissionMode)) {
|
|
96
|
+
return { type: "mode", mode: modeArg as PermissionMode };
|
|
97
|
+
}
|
|
98
|
+
// Shorthand aliases
|
|
99
|
+
if (modeArg === "auto") return { type: "mode", mode: "auto-accept" };
|
|
100
|
+
if (modeArg === "accept") return { type: "mode", mode: "auto-accept" };
|
|
101
|
+
return { type: "mode" }; // invalid mode → show picker
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (trimmed === "/cost") {
|
|
105
|
+
return { type: "cost" };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (trimmed === "/status") {
|
|
109
|
+
return { type: "status" };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (trimmed === "/help") {
|
|
113
|
+
return { type: "help" };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (trimmed.startsWith("/approve ")) {
|
|
117
|
+
const code = trimmed.slice(9).trim();
|
|
118
|
+
if (code) return { type: "approve", code };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// /cc alone — show command picker menu
|
|
122
|
+
if (trimmed === "/cc" || trimmed === "/cc ") {
|
|
123
|
+
return { type: "cc_menu" };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// /cc <slashCommand> [args] — Claude Code slash command pass-through
|
|
127
|
+
if (trimmed.startsWith("/cc ")) {
|
|
128
|
+
const rest = trimmed.slice(4).trim();
|
|
129
|
+
if (rest) {
|
|
130
|
+
const spaceIdx = rest.indexOf(" ");
|
|
131
|
+
const slashCommand = spaceIdx === -1 ? rest : rest.slice(0, spaceIdx);
|
|
132
|
+
const args = spaceIdx === -1 ? "" : rest.slice(spaceIdx + 1).trim();
|
|
133
|
+
return { type: "cc", slashCommand, args };
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// /dirs — list bookmarked directories
|
|
138
|
+
if (trimmed === "/dirs") {
|
|
139
|
+
return { type: "dirs" };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// /bookmark [path] [--name alias]
|
|
143
|
+
if (trimmed === "/bookmark" || trimmed.startsWith("/bookmark ")) {
|
|
144
|
+
const args = trimmed.slice(9).trim();
|
|
145
|
+
if (!args) return { type: "bookmark" };
|
|
146
|
+
const nameMatch = args.match(/--name\s+(\S+)/);
|
|
147
|
+
const name = nameMatch?.[1];
|
|
148
|
+
const path = args.replace(/--name\s+\S+/, "").trim() || undefined;
|
|
149
|
+
return { type: "bookmark", path, name };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Detect unknown /commands (single-word slash that isn't a known command)
|
|
153
|
+
if (trimmed.startsWith("/")) {
|
|
154
|
+
const match = trimmed.match(/^\/(\S+)/);
|
|
155
|
+
if (match?.[1] && !KNOWN_COMMANDS.has(match[1].toLowerCase())) {
|
|
156
|
+
return { type: "unknown_command", text: trimmed };
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return { type: "prompt", text: trimmed };
|
|
161
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import type { Config } from "./types.js";
|
|
4
|
+
|
|
5
|
+
const HOME = process.env.HOME ?? "/tmp";
|
|
6
|
+
const DATA_DIR = join(HOME, ".claude", "channels", "telegram");
|
|
7
|
+
|
|
8
|
+
function readEnvFile(path: string): Record<string, string> {
|
|
9
|
+
try {
|
|
10
|
+
const lines = readFileSync(path, "utf8").split("\n");
|
|
11
|
+
const result: Record<string, string> = {};
|
|
12
|
+
for (const line of lines) {
|
|
13
|
+
const match = line.match(/^([A-Z_]+)=(.+)$/);
|
|
14
|
+
if (match?.[1] && match[2]) result[match[1]] = match[2].trim();
|
|
15
|
+
}
|
|
16
|
+
return result;
|
|
17
|
+
} catch {
|
|
18
|
+
return {};
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function loadConfig(): Config {
|
|
23
|
+
const envFile = readEnvFile(join(DATA_DIR, ".env"));
|
|
24
|
+
|
|
25
|
+
const botToken = process.env.TELEGRAM_BOT_TOKEN ?? envFile.TELEGRAM_BOT_TOKEN ?? "";
|
|
26
|
+
|
|
27
|
+
return {
|
|
28
|
+
botToken,
|
|
29
|
+
dataDir: DATA_DIR,
|
|
30
|
+
allowlistPath: join(DATA_DIR, "allowlist.json"),
|
|
31
|
+
pollIntervalMs: 1_500,
|
|
32
|
+
pairingCodeTtlMs: 10 * 60 * 1_000, // 10 minutes
|
|
33
|
+
maxFileSizeBytes: 50 * 1024 * 1024, // 50 MB
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const TELEGRAM_API_BASE = "https://api.telegram.org";
|
|
38
|
+
export const PAIRING_CODE_LENGTH = 6;
|
|
39
|
+
export const TYPING_INTERVAL_MS = 4_500;
|
|
40
|
+
export const RELAY_PROMPT_TIMEOUT_MS = 120_000; // 2 minutes
|
package/src/html.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTML escape and formatting utilities for Telegram's HTML parse mode.
|
|
3
|
+
*
|
|
4
|
+
* Telegram HTML supports: <b>, <i>, <code>, <pre>, <a>, <s>, <u>, <tg-spoiler>.
|
|
5
|
+
* Only three characters require escaping: & < >
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** Escape text for safe inclusion in Telegram HTML messages. */
|
|
9
|
+
export function escapeHtml(text: string): string {
|
|
10
|
+
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Formatting helpers — each auto-escapes content. */
|
|
14
|
+
export const fmt = {
|
|
15
|
+
bold: (text: string) => `<b>${escapeHtml(text)}</b>`,
|
|
16
|
+
italic: (text: string) => `<i>${escapeHtml(text)}</i>`,
|
|
17
|
+
code: (text: string) => `<code>${escapeHtml(text)}</code>`,
|
|
18
|
+
pre: (text: string) => `<pre>${escapeHtml(text)}</pre>`,
|
|
19
|
+
preBlock: (text: string, language?: string) =>
|
|
20
|
+
language
|
|
21
|
+
? `<pre><code class="language-${escapeHtml(language)}">${escapeHtml(text)}</code></pre>`
|
|
22
|
+
: `<pre>${escapeHtml(text)}</pre>`,
|
|
23
|
+
link: (text: string, url: string) => `<a href="${escapeHtml(url)}">${escapeHtml(text)}</a>`,
|
|
24
|
+
strikethrough: (text: string) => `<s>${escapeHtml(text)}</s>`,
|
|
25
|
+
};
|