@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 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
+ [![GitHub Release](https://img.shields.io/github/v/release/alexnodeland/claude-telegram?style=flat&color=cc785c)](https://github.com/alexnodeland/claude-telegram/releases)
8
+ [![CI](https://img.shields.io/github/actions/workflow/status/alexnodeland/claude-telegram/ci.yml?branch=main&label=CI)](https://github.com/alexnodeland/claude-telegram/actions/workflows/ci.yml)
9
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
10
+ [![Runtime: Bun](https://img.shields.io/badge/Bun_%3E%3D1.1-f9f1e1?logo=bun&logoColor=000)](https://bun.sh)
11
+ [![Claude Code](https://img.shields.io/badge/Claude_Code-%3E%3D2.1-cc785c?logo=anthropic)](https://docs.anthropic.com/en/docs/claude-code)
12
+ [![MCP](https://img.shields.io/badge/MCP-Channel_Plugin-blue)](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
+ }
@@ -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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
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
+ };