Skip to content

Instantly share code, notes, and snippets.

@seedprod
Created March 4, 2026 00:26
Show Gist options
  • Select an option

  • Save seedprod/e3f84bf39f028aec580ed26d01a73658 to your computer and use it in GitHub Desktop.

Select an option

Save seedprod/e3f84bf39f028aec580ed26d01a73658 to your computer and use it in GitHub Desktop.
Telegram + Claude Code - Chat with your AI agent from your phone
# Required
TELEGRAM_BOT_TOKEN= # From @BotFather
ALLOWED_USERS= # Comma-separated Telegram user IDs
# Optional
WORK_DIR= # Where Claude CLI executes (default: ~)
AGENT_CMD=claude # CLI command (claude, codex, amp, opencode)
AGENT_MODEL= # Model override (--model flag)
#!/usr/bin/env node
/**
* Telegram Bot — AI CLI bridge (Claude + Codex).
* Zero dependencies. Receives messages from Telegram, passes them to an AI CLI,
* streams output back with live message editing.
*
* Supported agents:
* - claude (default) — Anthropic Claude Code CLI
* - codex — OpenAI Codex CLI
*
* Set AGENT_CMD in .env to switch (e.g. AGENT_CMD=codex).
*/
import { readFileSync, writeFileSync, mkdirSync, mkdtempSync, existsSync, unlinkSync, rmSync, createWriteStream } from 'node:fs';
import { resolve, extname, join } from 'node:path';
import { spawn, execFileSync } from 'node:child_process';
import { createInterface } from 'node:readline';
import { tmpdir, homedir } from 'node:os';
import { pipeline } from 'node:stream/promises';
import { Readable } from 'node:stream';
import { loadEnv } from './env.js';
import { toTelegramHTML, stripHTMLTags, htmlEscape, stripBackslashEscapes } from './html.js';
loadEnv();
// --- Config ---
const BOT_TOKEN = process.env.TELEGRAM_BOT_TOKEN;
const ALLOWED_USERS = (process.env.ALLOWED_USERS || '')
.split(',')
.map((u) => u.trim())
.filter(Boolean);
const WORK_DIR = process.env.GCTRL_WORK_DIR || process.env.WORK_DIR || homedir();
const AGENT_CMD = process.env.AGENT_CMD || 'claude';
const AGENT_MODEL = process.env.AGENT_MODEL || '';
const API_BASE = `https://api.telegram.org/bot${BOT_TOKEN}`;
const EDIT_INTERVAL = 500; // ms between message edits
const TYPING_INTERVAL = 4000; // ms between typing indicators
const SESSION_EXPIRY = 24 * 60 * 60 * 1000; // 24h in ms
const PROCESS_TIMEOUT = 5 * 60 * 1000; // 5 min — auto-kill hanging processes
const MAX_MESSAGE_LENGTH = 4000;
const ALLOWED_TOOLS = 'Read,Write,Edit,Bash,Glob,Grep,WebFetch,WebSearch,Skill,Task';
// --- Agent detection ---
const AGENT_TYPE = AGENT_CMD.includes('codex') ? 'codex' : 'claude';
// --- Telegram API helpers ---
async function tg(method, body) {
const res = await fetch(`${API_BASE}/${method}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const data = await res.json();
if (!data.ok) {
log(`Telegram API error [${method}]: ${data.description}`);
}
return data;
}
async function sendMessage(chatId, text, parseMode = 'HTML') {
const result = await tg('sendMessage', { chat_id: chatId, text, parse_mode: parseMode });
if (!result.ok && parseMode === 'HTML') {
// Fallback to plain text if HTML is rejected
return tg('sendMessage', { chat_id: chatId, text: stripHTMLTags(text) });
}
return result;
}
async function editMessageText(chatId, messageId, text, parseMode) {
const body = { chat_id: chatId, message_id: messageId, text };
if (parseMode) body.parse_mode = parseMode;
const result = await tg('editMessageText', body);
if (!result.ok && parseMode === 'HTML') {
// Fallback to plain text
return tg('editMessageText', { chat_id: chatId, message_id: messageId, text: stripHTMLTags(text) });
}
return result;
}
async function sendTyping(chatId) {
return tg('sendChatAction', { chat_id: chatId, action: 'typing' });
}
async function getFile(fileId) {
return tg('getFile', { file_id: fileId });
}
// --- Text helpers ---
function truncate(text) {
if (text.length > MAX_MESSAGE_LENGTH) {
return text.slice(0, MAX_MESSAGE_LENGTH) + '\n\n... (truncated)';
}
return text;
}
const COST_LINE_RE = /^Cost:.*\n?/gm;
function stripCostLine(text) {
return text.replace(COST_LINE_RE, '');
}
// --- Logging ---
function log(msg) {
console.log(`[${new Date().toISOString()}] ${msg}`);
}
// --- Session management (JSON file) ---
const SESSION_DIR = resolve(homedir(), '.gctrl');
const SESSION_FILE = resolve(SESSION_DIR, 'telegram-sessions.json');
function loadSessions() {
try {
return JSON.parse(readFileSync(SESSION_FILE, 'utf8'));
} catch {
return {};
}
}
function saveSessions(sessions) {
try {
if (!existsSync(SESSION_DIR)) {
mkdirSync(SESSION_DIR, { recursive: true });
}
writeFileSync(SESSION_FILE, JSON.stringify(sessions, null, 2));
} catch (err) {
log(`Failed to save sessions: ${err.message}`);
}
}
function getSession(userId) {
const sessions = loadSessions();
const userSessions = sessions[userId];
if (!userSessions || !Array.isArray(userSessions) || userSessions.length === 0) {
return null;
}
const active = userSessions.find((s) => s.active);
if (!active) return null;
// Check expiry
if (Date.now() - new Date(active.lastActive).getTime() > SESSION_EXPIRY) {
active.active = false;
saveSessions(sessions);
return null;
}
return active;
}
function deactivateAll(list) {
for (const s of list) s.active = false;
}
function saveSession(userId, sessionId, title) {
const sessions = loadSessions();
if (!sessions[userId]) sessions[userId] = [];
const existing = sessions[userId].find((s) => s.active);
if (existing) {
if (sessionId) existing.sessionId = sessionId;
if (title) existing.title = title;
existing.lastActive = new Date().toISOString();
} else {
deactivateAll(sessions[userId]);
sessions[userId].unshift({
sessionId: sessionId || '',
title: title || '',
lastActive: new Date().toISOString(),
active: true,
});
if (sessions[userId].length > 10) sessions[userId].length = 10;
}
saveSessions(sessions);
}
function clearSession(userId) {
const sessions = loadSessions();
if (sessions[userId]) {
deactivateAll(sessions[userId]);
saveSessions(sessions);
}
}
function getRecentSessions(userId, limit = 5) {
const sessions = loadSessions();
return (sessions[userId] || []).slice(0, limit);
}
function activateSession(userId, index) {
const sessions = loadSessions();
if (!sessions[userId] || index >= sessions[userId].length) return false;
deactivateAll(sessions[userId]);
sessions[userId][index].active = true;
sessions[userId][index].lastActive = new Date().toISOString();
saveSessions(sessions);
return true;
}
// --- Active user tracking (one request at a time per user) ---
/** @type {Map<string, AbortController>} */
const activeUsers = new Map();
function acquireUser(chatId, userId) {
if (activeUsers.has(userId)) {
sendMessage(chatId, 'Still processing your previous message. Please wait...');
return null;
}
const controller = new AbortController();
controller._timeout = setTimeout(() => {
log(`Process timeout for user ${userId} — aborting after ${PROCESS_TIMEOUT / 1000}s`);
controller.abort();
}, PROCESS_TIMEOUT);
activeUsers.set(userId, controller);
return controller;
}
function releaseUser(userId) {
const controller = activeUsers.get(userId);
if (controller?._timeout) clearTimeout(controller._timeout);
activeUsers.delete(userId);
}
function interruptUser(userId) {
const controller = activeUsers.get(userId);
if (!controller) return false;
controller.abort();
return true;
}
// --- Tool display names ---
const TOOL_DISPLAY = {
Read: 'Reading',
Write: 'Writing',
Edit: 'Editing',
Bash: 'Running command',
Glob: 'Searching files',
Grep: 'Searching code',
WebFetch: 'Fetching page',
WebSearch: 'Searching web',
};
// --- CLI streaming (agent-agnostic event model) ---
//
// Both parsers normalise CLI output into a common event stream:
// { type: 'chunk', content } — incremental text
// { type: 'snapshot', content } — full-text replacement
// { type: 'tool_start', content } — tool/command name
// { type: 'tool_done' } — tool finished
// { type: 'session_id', content } — session id for resume
// { type: 'done', content, sessionId }
// { type: 'error', content }
// ── Claude parser ──────────────────────────────────────────────────────────
function parseClaudeLine(line) {
let raw;
try {
raw = JSON.parse(line);
} catch {
return [];
}
const type = raw.type;
const events = [];
switch (type) {
case 'system':
if (raw.session_id) {
events.push({ type: 'session_id', content: raw.session_id });
}
break;
case 'assistant': {
const content = raw.message?.content;
if (!Array.isArray(content)) break;
let text = '';
for (const block of content) {
if (block.type === 'text' && block.text) text += block.text;
if (block.type === 'tool_use' && block.name) {
events.push({ type: 'tool_start', content: block.name });
}
}
if (text) events.push({ type: 'snapshot', content: text });
break;
}
case 'user': {
const content = raw.message?.content;
if (!Array.isArray(content)) break;
for (const block of content) {
if (block.type === 'tool_result') {
events.push({ type: 'tool_done' });
break;
}
}
break;
}
case 'stream_event': {
const event = raw.event;
if (!event) break;
if (event.type === 'content_block_delta') {
const delta = event.delta;
if (delta?.type === 'text_delta' && delta.text) {
events.push({ type: 'chunk', content: delta.text });
}
} else if (event.type === 'content_block_start') {
const block = event.content_block;
if (block?.type === 'tool_use' && block.name) {
events.push({ type: 'tool_start', content: block.name });
}
}
break;
}
case 'result':
events.push({
type: 'done',
content: raw.result || '',
sessionId: raw.session_id || '',
});
break;
}
return events;
}
// ── Codex parser ───────────────────────────────────────────────────────────
function parseCodexLine(line) {
let raw;
try {
raw = JSON.parse(line);
} catch {
return [];
}
const type = raw.type;
const events = [];
switch (type) {
case 'thread.started':
if (raw.thread_id) {
events.push({ type: 'session_id', content: raw.thread_id });
}
break;
case 'item.started': {
const item = raw.item;
if (!item) break;
if (item.type === 'command_execution' && item.command) {
events.push({ type: 'tool_start', content: 'Running command' });
} else if (item.type === 'file_change') {
events.push({ type: 'tool_start', content: 'Editing file' });
} else if (item.type === 'mcp_tool_call') {
events.push({ type: 'tool_start', content: item.tool_name || 'Tool call' });
} else if (item.type === 'web_search') {
events.push({ type: 'tool_start', content: 'Searching web' });
}
break;
}
case 'item.completed': {
const item = raw.item;
if (!item) break;
if (item.type === 'agent_message' && item.text) {
events.push({ type: 'snapshot', content: item.text });
} else {
// Tool/command finished
events.push({ type: 'tool_done' });
}
break;
}
case 'turn.completed':
// Final turn — extract the last agent message if present
events.push({ type: 'done', content: '', sessionId: '' });
break;
case 'turn.failed':
case 'error':
events.push({ type: 'error', content: raw.message || raw.error || 'Codex error' });
break;
}
return events;
}
// ── Spawn & stream ─────────────────────────────────────────────────────────
function buildAgentArgs(prompt, sessionId) {
if (AGENT_TYPE === 'codex') {
// Codex: `codex exec "prompt" --json --full-auto`
// Resume: `codex exec resume --last --json --full-auto`
if (sessionId) {
const args = ['exec', 'resume', '--last', '--json', '--full-auto'];
if (AGENT_MODEL) args.push('-m', AGENT_MODEL);
// Append the new prompt via stdin or as trailing arg
args.push(prompt);
return args;
}
const args = ['exec', prompt, '--json', '--full-auto'];
if (AGENT_MODEL) args.push('-m', AGENT_MODEL);
return args;
}
// Claude: `claude -p "prompt" --output-format stream-json ...`
const args = ['-p', prompt, '--verbose', '--output-format', 'stream-json', '--include-partial-messages', '--allowedTools', ALLOWED_TOOLS];
if (AGENT_MODEL) args.push('--model', AGENT_MODEL);
if (sessionId) args.push('--resume', sessionId);
return args;
}
const parseLine = AGENT_TYPE === 'codex' ? parseCodexLine : parseClaudeLine;
async function* streamAgent(prompt, sessionId, signal) {
const args = buildAgentArgs(prompt, sessionId);
// Strip CLAUDECODE env to allow nested launches
const env = { ...process.env };
delete env.CLAUDECODE;
const proc = spawn(AGENT_CMD, args, {
cwd: WORK_DIR,
env,
stdio: ['ignore', 'pipe', 'pipe'],
});
// Catch spawn errors (e.g. ENOENT if agent binary missing) so they
// don't crash the whole bot — surface as a stream error instead.
let spawnError = null;
proc.on('error', (err) => {
spawnError = err;
});
const onAbort = () => {
proc.kill('SIGTERM');
setTimeout(() => proc.kill('SIGKILL'), 3000);
};
signal?.addEventListener('abort', onAbort, { once: true });
const rl = createInterface({ input: proc.stdout, crlfDelay: Infinity });
// Capture close promise BEFORE readline loop — if we register after,
// the process may have already exited and the event already fired,
// causing the promise to hang forever and releaseUser to never run.
const closed = new Promise((resolve) => proc.on('close', resolve));
const stderrRl = createInterface({ input: proc.stderr, crlfDelay: Infinity });
stderrRl.on('line', (line) => log(`[${AGENT_TYPE} stderr] ${line}`));
for await (const line of rl) {
if (!line) continue;
yield* parseLine(line);
}
signal?.removeEventListener('abort', onAbort);
await closed;
if (spawnError) {
yield { type: 'error', content: `Failed to start ${AGENT_TYPE}: ${spawnError.message}` };
} else if (proc.exitCode !== 0 && !signal?.aborted) {
yield { type: 'error', content: `${AGENT_TYPE} exited with code ${proc.exitCode}` };
}
}
/**
* Stream an agent response and live-edit a Telegram message.
* Works with both Claude and Codex via the normalised event model.
*/
async function streamAgentResponse(chatId, messageId, userId, prompt, prefix = '') {
const session = getSession(userId);
const prevSessionId = session?.sessionId || '';
const controller = activeUsers.get(userId);
const signal = controller?.signal;
let accumulated = '';
let activeTool = '';
let lastEdit = 0;
let lastTyping = Date.now();
let sessionId = '';
function renderDisplay() {
let raw = stripBackslashEscapes(accumulated);
if (activeTool) {
const label = TOOL_DISPLAY[activeTool] || activeTool;
if (raw) raw += '\n\n';
raw += `\u23f3 ${label}...`;
}
return raw;
}
async function maybeEdit(force = false) {
const now = Date.now();
if (!force && now - lastEdit < EDIT_INTERVAL) return;
const text = renderDisplay();
if (!text) return;
await editMessageText(chatId, messageId, truncate(prefix + text));
lastEdit = now;
}
async function maybeTyping() {
if (Date.now() - lastTyping > TYPING_INTERVAL) {
sendTyping(chatId); // fire-and-forget
lastTyping = Date.now();
}
}
try {
for await (const event of streamAgent(prompt, prevSessionId, signal)) {
await maybeTyping();
switch (event.type) {
case 'chunk':
accumulated += event.content;
await maybeEdit();
break;
case 'snapshot':
if (event.content) {
accumulated = event.content;
await maybeEdit();
}
break;
case 'tool_start':
activeTool = event.content;
await maybeEdit(true);
break;
case 'tool_done':
activeTool = '';
break;
case 'session_id':
sessionId = event.content;
break;
case 'done':
if (event.sessionId) sessionId = event.sessionId;
activeTool = '';
let finalText = stripCostLine(accumulated);
if (!finalText && event.content) finalText = stripCostLine(event.content);
if (!finalText) finalText = `No response from ${AGENT_TYPE}.`;
await editMessageText(chatId, messageId, truncate(prefix + toTelegramHTML(finalText)), 'HTML');
break;
case 'error':
await editMessageText(chatId, messageId, truncate(prefix + 'Error: ' + htmlEscape(event.content)), 'HTML');
break;
}
}
} catch (err) {
if (signal?.aborted) {
// Interrupted
if (accumulated) {
await editMessageText(
chatId,
messageId,
truncate(prefix + toTelegramHTML(accumulated) + '\n\n<i>(interrupted)</i>'),
'HTML',
);
}
} else {
log(`Stream error: ${err.message}`);
await editMessageText(chatId, messageId, truncate(prefix + 'Error: ' + htmlEscape(err.message)), 'HTML');
}
}
// Save session (Codex: thread_id, Claude: session_id)
if (!sessionId) sessionId = prevSessionId;
if (sessionId) {
const title = prompt.slice(0, 50);
saveSession(userId, sessionId, title);
}
}
// --- Whisper voice transcription ---
let whisperPath = null;
function initWhisper() {
const venvPath = resolve(homedir(), '.cc-tools-venv', 'bin', 'mlx_whisper');
// Check known venv path first, then search PATH
if (existsSync(venvPath)) {
whisperPath = venvPath;
log(`Whisper found: ${whisperPath}`);
return;
}
for (const name of ['mlx_whisper', 'whisper']) {
try {
execFileSync('which', [name], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
whisperPath = name;
log(`Whisper found: ${whisperPath}`);
return;
} catch {}
}
log('Warning: No whisper binary found. Voice messages will not be transcribed.');
}
async function downloadTelegramFile(fileId, prefix) {
const result = await getFile(fileId);
if (!result.ok) throw new Error('Failed to get file info');
const filePath = result.result.file_path;
const url = `https://api.telegram.org/file/bot${BOT_TOKEN}/${filePath}`;
const ext = extname(filePath) || '.ogg';
const tmpPath = join(tmpdir(), `telegram-${prefix}-${Date.now()}${ext}`);
const res = await fetch(url);
if (!res.ok) throw new Error(`Download failed: ${res.status}`);
const fileStream = createWriteStream(tmpPath);
await pipeline(Readable.fromWeb(res.body), fileStream);
return tmpPath;
}
function transcribe(audioPath) {
const tmpDir = mkdtempSync(join(tmpdir(), 'telegram-whisper-'));
try {
execFileSync(whisperPath, [audioPath, '--model', 'mlx-community/whisper-small-mlx', '--output-format', 'txt', '--output-dir', tmpDir, '--output-name', 'transcript', '--language', 'en'], {
encoding: 'utf8',
timeout: 120000,
env: { ...process.env, PATH: `${process.env.PATH || ''}:/opt/homebrew/bin:/usr/local/bin` },
});
const txtPath = join(tmpDir, 'transcript.txt');
if (existsSync(txtPath)) {
return readFileSync(txtPath, 'utf8').trim();
}
return '';
} finally {
rmSync(tmpDir, { recursive: true, force: true });
}
}
// --- File type helpers ---
const IMAGE_EXTS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.svg']);
const TEXT_EXTS = new Set([
'.txt', '.md', '.csv', '.json', '.xml', '.yaml', '.yml', '.toml', '.html', '.css', '.js', '.ts', '.go', '.py', '.rs', '.java', '.sh', '.bash', '.zsh', '.log', '.ini', '.cfg', '.conf',
]);
function buildFilePrompt(path, filename, ext, caption) {
if (IMAGE_EXTS.has(ext)) {
return `I've attached an image at the path ${path}. Please look at it using the Read tool and then: ${caption}`;
}
if (TEXT_EXTS.has(ext) || ext === '.pdf') {
return `I've attached a file at the path ${path}. Please read it using the Read tool and then: ${caption}`;
}
return `I've attached a file at the path ${path} (filename: ${filename}). Try to read it and then: ${caption}`;
}
// --- Message handlers ---
async function handleChat(msg) {
const userId = String(msg.from.id);
const chatId = msg.chat.id;
let text = msg.text;
// Interrupt with ! prefix
if (text.startsWith('!')) {
text = text.slice(1).trim();
if (interruptUser(userId)) {
for (let i = 0; i < 50 && activeUsers.has(userId); i++) {
await new Promise((r) => setTimeout(r, 100));
}
}
if (!text) return;
}
if (!acquireUser(chatId, userId)) return;
try {
sendTyping(chatId);
const sent = await sendMessage(chatId, 'Thinking...');
if (!sent.ok) return;
await streamAgentResponse(chatId, sent.result.message_id, userId, text);
} finally {
releaseUser(userId);
}
}
async function handleVoice(msg) {
const userId = String(msg.from.id);
const chatId = msg.chat.id;
if (!whisperPath) {
await sendMessage(chatId, 'Voice transcription not available. Send text instead.\n\n<i>Tip: Install mlx-whisper to enable voice support.</i>');
return;
}
if (!acquireUser(chatId, userId)) return;
try {
const sent = await sendMessage(chatId, '\ud83c\udfa4 Transcribing voice message...');
if (!sent.ok) return;
const messageId = sent.result.message_id;
const fileId = msg.voice?.file_id || msg.audio?.file_id;
if (!fileId) {
await editMessageText(chatId, messageId, 'Unsupported audio format.');
return;
}
let audioPath;
try {
audioPath = await downloadTelegramFile(fileId, 'voice');
} catch (err) {
await editMessageText(chatId, messageId, `Failed to download voice: ${err.message}`);
return;
}
let transcript;
try {
transcript = transcribe(audioPath);
} catch (err) {
await editMessageText(chatId, messageId, `Transcription failed: ${err.message}`);
return;
} finally {
try { unlinkSync(audioPath); } catch {}
}
if (!transcript) {
await editMessageText(chatId, messageId, 'Could not transcribe voice message. Please try again.');
return;
}
const prefix = `\ud83c\udfa4 "${transcript}"\n\n`;
await editMessageText(chatId, messageId, prefix + 'Thinking...');
await streamAgentResponse(chatId, messageId, userId, transcript, prefix);
} finally {
releaseUser(userId);
}
}
async function handlePhoto(msg) {
const userId = String(msg.from.id);
const chatId = msg.chat.id;
if (!acquireUser(chatId, userId)) return;
try {
const sent = await sendMessage(chatId, '\ud83d\udcf7 Analyzing image...');
if (!sent.ok) return;
const messageId = sent.result.message_id;
const photos = msg.photo;
if (!photos || photos.length === 0) {
await editMessageText(chatId, messageId, 'No photo found.');
return;
}
const bestPhoto = photos[photos.length - 1];
let imgPath;
try {
imgPath = await downloadTelegramFile(bestPhoto.file_id, 'photo');
} catch (err) {
await editMessageText(chatId, messageId, `Failed to download image: ${err.message}`);
return;
}
const caption = msg.caption || 'Describe what you see in this image.';
const prompt = `I've attached an image at the path ${imgPath}. Please look at it using the Read tool and then: ${caption}`;
try {
await streamAgentResponse(chatId, messageId, userId, prompt);
} finally {
try { unlinkSync(imgPath); } catch {}
}
} finally {
releaseUser(userId);
}
}
async function handleDocument(msg) {
const userId = String(msg.from.id);
const chatId = msg.chat.id;
if (!acquireUser(chatId, userId)) return;
try {
const doc = msg.document;
if (!doc) return;
if (doc.file_size > 10 * 1024 * 1024) {
await sendMessage(chatId, 'File too large. Maximum size is 10MB.');
return;
}
const sent = await sendMessage(chatId, '\ud83d\udcc4 Reading document...');
if (!sent.ok) return;
const messageId = sent.result.message_id;
let docPath;
try {
docPath = await downloadTelegramFile(doc.file_id, 'doc');
} catch (err) {
await editMessageText(chatId, messageId, `Failed to download file: ${err.message}`);
return;
}
const caption = msg.caption || 'Analyze this file and summarize its contents.';
const ext = extname(doc.file_name || '').toLowerCase();
const prompt = buildFilePrompt(docPath, doc.file_name || 'file', ext, caption);
try {
await streamAgentResponse(chatId, messageId, userId, prompt);
} finally {
try { unlinkSync(docPath); } catch {}
}
} finally {
releaseUser(userId);
}
}
// --- Commands ---
async function handleCommand(msg) {
const text = msg.text || '';
const cmd = text.split(/\s/)[0].split('@')[0]; // strip @botname
const args = text.slice(cmd.length).trim();
switch (cmd) {
case '/help':
await sendMessage(msg.chat.id,
`<b>Telegram Bot</b> (${AGENT_TYPE})\n\n` +
'Send any message to start a conversation.\n' +
'Prefix with <code>!</code> to interrupt a running response.\n\n' +
'<b>Commands:</b>\n' +
'/new - Start new conversation\n' +
'/resume (N) - List or switch to a previous session\n' +
'/help - This message\n\n' +
'<b>Media:</b>\n' +
'Send photos, documents, or voice messages.\n\n' +
'<i>(N) = optional</i>',
);
break;
case '/start':
await sendMessage(msg.chat.id, `Welcome! Send a message to chat with ${AGENT_TYPE}, or use /help for commands.`);
break;
case '/new':
clearSession(String(msg.from.id));
await sendMessage(msg.chat.id, 'Started new session. Previous sessions preserved \u2014 use /resume to switch back.');
break;
case '/resume':
await handleResumeCommand(msg, args);
break;
default:
await sendMessage(msg.chat.id, `Unknown command: ${cmd}\nUse /help for available commands.`);
}
}
async function handleResumeCommand(msg, args) {
const userId = String(msg.from.id);
if (!args) {
const sessions = getRecentSessions(userId, 5);
if (sessions.length === 0) {
await sendMessage(msg.chat.id, 'No previous sessions found.');
return;
}
let text = '<b>Recent Sessions:</b>\n\n';
sessions.forEach((s, i) => {
const title = s.title || '(untitled)';
const active = s.active ? ' \u2705' : '';
const date = new Date(s.lastActive);
const dateStr = date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
text += `${i + 1}. ${htmlEscape(title)} \u2014 <i>${dateStr}</i>${active}\n`;
});
text += '\nUse <code>/resume &lt;number&gt;</code> to switch back.';
await sendMessage(msg.chat.id, text);
return;
}
const n = parseInt(args, 10);
if (isNaN(n) || n < 1) {
await sendMessage(msg.chat.id, 'Usage: /resume &lt;number&gt; (from /resume list)');
return;
}
const sessions = getRecentSessions(userId, 5);
if (n > sessions.length) {
await sendMessage(msg.chat.id, 'Invalid session number. Use /resume to see the list.');
return;
}
if (activateSession(userId, n - 1)) {
const title = sessions[n - 1].title || '(untitled)';
await sendMessage(msg.chat.id, `Resumed session: ${htmlEscape(title)}`);
} else {
await sendMessage(msg.chat.id, 'Failed to switch session.');
}
}
// --- Update handler ---
async function handleUpdate(update) {
const msg = update.message;
if (!msg) return;
const userId = String(msg.from?.id);
if (!ALLOWED_USERS.includes(userId)) {
log(`Unauthorized message from user ${userId}`);
return;
}
try {
if (msg.text && msg.text.startsWith('/')) {
await handleCommand(msg);
} else if (msg.text) {
await handleChat(msg);
} else if (msg.voice || msg.audio) {
await handleVoice(msg);
} else if (msg.photo) {
await handlePhoto(msg);
} else if (msg.document) {
await handleDocument(msg);
}
} catch (err) {
log(`Error handling update: ${err.message}`);
}
}
// --- Polling loop ---
async function fetchUpdates(offset, timeout) {
return tg('getUpdates', { offset, timeout });
}
async function poll(offset = 0) {
// Drain stale updates that queued while the bot was down.
try {
const drain = await fetchUpdates(offset, 0);
if (drain.ok && drain.result.length > 0) {
const skipped = drain.result.length;
offset = drain.result[drain.result.length - 1].update_id + 1;
log(`Skipped ${skipped} stale update(s) from while bot was down`);
}
} catch (err) {
log(`Drain error: ${err.message}`);
}
while (true) {
try {
const data = await fetchUpdates(offset, 30);
if (!data.ok) {
log(`getUpdates error: ${data.description}`);
await new Promise((r) => setTimeout(r, 5000));
continue;
}
for (const update of data.result) {
offset = update.update_id + 1;
handleUpdate(update).catch((err) => log(`Update handler error: ${err.message}`));
}
} catch (err) {
log(`Poll error: ${err.message}`);
await new Promise((r) => setTimeout(r, 5000));
}
}
}
// --- Service management ---
async function cmdTest() {
const result = await tg('getMe', {});
if (result.ok) {
console.log(`Connected: @${result.result.username} (${result.result.first_name})`);
} else {
console.error(`Connection failed: ${result.description}`);
process.exit(1);
}
}
// --- Main ---
if (!BOT_TOKEN) {
console.error('TELEGRAM_BOT_TOKEN not set. Create a .env file first.');
process.exit(1);
}
const cmd = process.argv[2];
if (cmd === '--test' || cmd === 'test') {
await cmdTest();
} else {
if (ALLOWED_USERS.length === 0) {
console.error('ALLOWED_USERS not set. Add your Telegram user ID to .env');
process.exit(1);
}
const me = await tg('getMe', {});
if (me.ok) {
log(`Authorized as @${me.result.username}`);
}
log(`Allowed users: ${ALLOWED_USERS.join(', ')}`);
log(`Working directory: ${WORK_DIR}`);
log(`Agent: ${AGENT_CMD} (${AGENT_TYPE})`);
// Graceful shutdown — abort all active processes so restarts start clean
process.on('SIGTERM', () => {
log('SIGTERM received — cleaning up active processes...');
for (const [userId, controller] of activeUsers) {
controller.abort();
releaseUser(userId);
}
process.exit(0);
});
initWhisper();
log('Listening for messages...');
await poll();
}

Telegram Bot Commands

Chat Commands

Command Description
/new Start a new conversation. Previous sessions are preserved for /resume.
/resume List recent sessions (last 5).
/resume N Switch to session number N from the list.
/help Show available commands.
/start Welcome message (sent automatically on first interaction).

Message Types

Type Behavior
Text Sent to Claude CLI as a prompt. Response streamed back with live editing.
!text Interrupts any running response, then sends text as new prompt.
! Interrupts running response (no new prompt).
Voice/Audio Transcribed via whisper, then sent to Claude as text.
Photo Downloaded, passed to Claude with "look at it using the Read tool".
Document Downloaded, passed to Claude with extension-aware prompt.

Session Behavior

  • Sessions persist Claude's conversation context via --resume
  • One active session per user at a time
  • Sessions expire after 24 hours of inactivity
  • /new deactivates the current session but preserves it for /resume
  • Up to 10 sessions stored per user

Environment Variables

Variable Required Default Description
TELEGRAM_BOT_TOKEN Yes Bot token from @BotFather
ALLOWED_USERS Yes Comma-separated Telegram user IDs
WORK_DIR No ~ Working directory for Claude CLI
AGENT_CMD No claude CLI command to use
AGENT_MODEL No Model override (--model flag)
/**
* Shared .env loader for the Telegram skill scripts.
* Reads key=value pairs from the skill's .env file into process.env
* without overwriting existing environment variables.
*/
import { readFileSync } from 'node:fs';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const SKILL_DIR = resolve(__dirname, '..');
export function loadEnv() {
const envPath = resolve(SKILL_DIR, '.env');
try {
for (const line of readFileSync(envPath, 'utf8').split('\n')) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const eq = trimmed.indexOf('=');
if (eq === -1) continue;
const key = trimmed.slice(0, eq).trim();
const val = trimmed.slice(eq + 1).trim().replace(/^["']|["']$/g, '');
if (!process.env[key]) process.env[key] = val;
}
} catch {}
}
/**
* Markdown → Telegram HTML converter.
* Zero dependencies. Handles the subset of HTML that Telegram supports:
* <b>, <i>, <s>, <code>, <pre>, <a>, <blockquote>
*
* Ported from Go goldmark-based converter in internal/bot/telegram_html.go
*/
export function htmlEscape(s) {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
export function stripBackslashEscapes(s) {
return s.replace(/\\([!.#()+\->={|~?])/g, '$1');
}
/**
* Convert markdown text to Telegram-safe HTML.
* During streaming, use plain text (no conversion) to avoid broken tags.
* Only call this for the final message.
*/
export function toTelegramHTML(md) {
md = stripBackslashEscapes(md);
const lines = md.split('\n');
const result = [];
let i = 0;
while (i < lines.length) {
const line = lines[i];
// Fenced code block
if (line.startsWith('```')) {
i++;
const codeLines = [];
while (i < lines.length && !lines[i].startsWith('```')) {
codeLines.push(lines[i]);
i++;
}
if (i < lines.length) i++; // skip closing ```
result.push('<pre>' + htmlEscape(codeLines.join('\n')) + '</pre>');
continue;
}
// Heading
const headingMatch = line.match(/^(#{1,6})\s+(.+)/);
if (headingMatch) {
result.push('<b>' + convertInline(headingMatch[2]) + '</b>');
i++;
continue;
}
// Blockquote
if (line.startsWith('> ') || line === '>') {
const quoteLines = [];
while (i < lines.length && (lines[i].startsWith('> ') || lines[i] === '>')) {
quoteLines.push(lines[i].replace(/^>\s?/, ''));
i++;
}
result.push('<blockquote>' + convertInline(quoteLines.join('\n')) + '</blockquote>');
continue;
}
// Unordered list
if (/^[\-\*]\s/.test(line)) {
while (i < lines.length && /^[\-\*]\s/.test(lines[i])) {
result.push('• ' + convertInline(lines[i].replace(/^[\-\*]\s+/, '')));
i++;
}
continue;
}
// Ordered list
if (/^\d+\.\s/.test(line)) {
while (i < lines.length && /^\d+\.\s/.test(lines[i])) {
const m = lines[i].match(/^(\d+\.)\s+(.*)/);
result.push(m[1] + ' ' + convertInline(m[2]));
i++;
}
continue;
}
// Empty line
if (line.trim() === '') {
result.push('');
i++;
continue;
}
// Regular paragraph
result.push(convertInline(line));
i++;
}
return result.join('\n').replace(/\n{3,}/g, '\n\n').trim();
}
/**
* Convert inline markdown: bold, italic, strikethrough, code, links.
*/
function convertInline(text) {
// Inline code (must be first to avoid processing markdown inside code)
text = text.replace(/`([^`]+)`/g, (_, code) => '<code>' + htmlEscape(code) + '</code>');
// Bold+italic ***text***
text = text.replace(/\*\*\*(.+?)\*\*\*/g, '<b><i>$1</i></b>');
// Bold **text** or __text__
text = text.replace(/\*\*(.+?)\*\*/g, '<b>$1</b>');
text = text.replace(/__(.+?)__/g, '<b>$1</b>');
// Italic *text* or _text_ (but not inside words for _)
text = text.replace(/(?<!\w)\*([^\s*](?:.*?[^\s*])?)\*(?!\w)/g, '<i>$1</i>');
text = text.replace(/(?<!\w)_([^\s_](?:.*?[^\s_])?)_(?!\w)/g, '<i>$1</i>');
// Strikethrough ~~text~~
text = text.replace(/~~(.+?)~~/g, '<s>$1</s>');
// Links [text](url)
text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, label, url) => {
return '<a href="' + htmlEscape(url) + '">' + label + '</a>';
});
// Escape remaining plain text characters (only those not already in tags)
// We handle this by escaping during inline code processing above
// and letting the block-level handler escape full lines for non-inline content
return text;
}
/**
* Strip HTML tags for plain text fallback when Telegram rejects malformed HTML.
*/
export function stripHTMLTags(s) {
s = s.replace(/<[^>]+>/g, '');
s = s.replace(/&amp;/g, '&');
s = s.replace(/&lt;/g, '<');
s = s.replace(/&gt;/g, '>');
return s;
}
#!/bin/bash
set -e
# Telegram Bot — OS service installer
# Detects macOS (launchd) or Linux (systemd) and installs accordingly.
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
SKILL_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
BOT_JS="$SCRIPT_DIR/bot.js"
NODE_BIN="$(which node)"
SERVICE_NAME="ai.gctrl.telegram"
if [ ! -f "$BOT_JS" ]; then
echo "Error: bot.js not found at $BOT_JS"
exit 1
fi
if [ -z "$NODE_BIN" ]; then
echo "Error: node not found in PATH"
exit 1
fi
if [ ! -f "$SKILL_DIR/.env" ]; then
echo "Error: .env not found. Copy .env.example to .env and configure it first."
exit 1
fi
# Resolve agent CLI path and persist to .env so launchd/systemd can find it
AGENT_CMD="${AGENT_CMD:-claude}"
AGENT_BIN="$(which "$AGENT_CMD" 2>/dev/null || true)"
if [ -z "$AGENT_BIN" ]; then
echo "Error: '$AGENT_CMD' not found in PATH"
echo "Install your AI agent CLI (claude, codex, etc.) and try again."
exit 1
fi
if ! grep -q '^AGENT_CMD=' "$SKILL_DIR/.env" 2>/dev/null; then
echo "AGENT_CMD=$AGENT_BIN" >> "$SKILL_DIR/.env"
echo "Resolved agent CLI: $AGENT_BIN"
fi
# ── macOS (launchd) ──────────────────────────────────────────────────────────
install_launchd() {
local PLIST_DIR="$HOME/Library/LaunchAgents"
local PLIST_PATH="$PLIST_DIR/$SERVICE_NAME.plist"
local LOG_DIR="$HOME/.gctrl/logs"
mkdir -p "$PLIST_DIR" "$LOG_DIR"
# Unload if already installed
launchctl bootout "gui/$(id -u)/$SERVICE_NAME" 2>/dev/null || true
cat > "$PLIST_PATH" <<PLIST
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>$SERVICE_NAME</string>
<key>ProgramArguments</key>
<array>
<string>$NODE_BIN</string>
<string>$BOT_JS</string>
</array>
<key>WorkingDirectory</key>
<string>$SKILL_DIR</string>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
<string>$LOG_DIR/telegram.log</string>
<key>StandardErrorPath</key>
<string>$LOG_DIR/telegram.error.log</string>
</dict>
</plist>
PLIST
launchctl bootstrap "gui/$(id -u)" "$PLIST_PATH"
echo "Installed launchd service: $SERVICE_NAME"
echo " plist: $PLIST_PATH"
echo " logs: $LOG_DIR/"
echo ""
echo "Commands:"
echo " launchctl kickstart gui/$(id -u)/$SERVICE_NAME # start now"
echo " launchctl bootout gui/$(id -u)/$SERVICE_NAME # stop"
echo " tail -f $LOG_DIR/telegram.log # watch logs"
}
# ── Linux (systemd) ──────────────────────────────────────────────────────────
install_systemd() {
local SERVICE_DIR="$HOME/.config/systemd/user"
local SERVICE_PATH="$SERVICE_DIR/$SERVICE_NAME.service"
local LOG_DIR="$HOME/.gctrl/logs"
mkdir -p "$SERVICE_DIR" "$LOG_DIR"
systemctl --user stop "$SERVICE_NAME" 2>/dev/null || true
cat > "$SERVICE_PATH" <<SERVICE
[Unit]
Description=Telegram Bot (Claude CLI bridge)
After=network.target
[Service]
Type=simple
ExecStart=$NODE_BIN $BOT_JS
WorkingDirectory=$SKILL_DIR
Restart=always
RestartSec=10
[Install]
WantedBy=default.target
SERVICE
systemctl --user daemon-reload
systemctl --user enable "$SERVICE_NAME"
systemctl --user start "$SERVICE_NAME"
echo "Installed systemd service: $SERVICE_NAME"
echo " unit: $SERVICE_PATH"
echo ""
echo "Commands:"
echo " systemctl --user status $SERVICE_NAME # check status"
echo " systemctl --user stop $SERVICE_NAME # stop"
echo " systemctl --user restart $SERVICE_NAME # restart"
echo " journalctl --user -u $SERVICE_NAME -f # watch logs"
}
# ── Log rotation timer (shared, idempotent) ──────────────────────────────────
ROTATE_SCRIPT="$SCRIPT_DIR/rotate-logs.sh"
install_log_rotation_launchd() {
local PLIST_PATH="$HOME/Library/LaunchAgents/ai.gctrl.rotate-logs.plist"
local LOG_DIR="$HOME/.gctrl/logs"
[ -f "$PLIST_PATH" ] && return
[ -f "$ROTATE_SCRIPT" ] || return
chmod +x "$ROTATE_SCRIPT"
local ABS_ROTATE="$(cd "$(dirname "$ROTATE_SCRIPT")" && pwd)/$(basename "$ROTATE_SCRIPT")"
cat > "$PLIST_PATH" <<PLIST
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>ai.gctrl.rotate-logs</string>
<key>ProgramArguments</key>
<array>
<string>/bin/bash</string>
<string>$ABS_ROTATE</string>
</array>
<key>StartCalendarInterval</key>
<dict>
<key>Hour</key>
<integer>3</integer>
<key>Minute</key>
<integer>0</integer>
</dict>
<key>StandardOutPath</key>
<string>$LOG_DIR/rotate.log</string>
<key>StandardErrorPath</key>
<string>$LOG_DIR/rotate.log</string>
</dict>
</plist>
PLIST
launchctl bootstrap "gui/$(id -u)" "$PLIST_PATH"
echo "Installed daily log rotation: ai.gctrl.rotate-logs (3:00 AM)"
}
install_log_rotation_systemd() {
local SERVICE_DIR="$HOME/.config/systemd/user"
local TIMER_PATH="$SERVICE_DIR/ai.gctrl.rotate-logs.timer"
local SERVICE_PATH="$SERVICE_DIR/ai.gctrl.rotate-logs.service"
[ -f "$TIMER_PATH" ] && return
[ -f "$ROTATE_SCRIPT" ] || return
chmod +x "$ROTATE_SCRIPT"
local ABS_ROTATE="$(cd "$(dirname "$ROTATE_SCRIPT")" && pwd)/$(basename "$ROTATE_SCRIPT")"
cat > "$SERVICE_PATH" <<SERVICE
[Unit]
Description=^ Ground Ctrl Log Rotation
[Service]
Type=oneshot
ExecStart=/bin/bash $ABS_ROTATE
SERVICE
cat > "$TIMER_PATH" <<TIMER
[Unit]
Description=Daily log rotation for ^ Ground Ctrl
[Timer]
OnCalendar=daily
Persistent=true
[Install]
WantedBy=timers.target
TIMER
systemctl --user daemon-reload
systemctl --user enable --now ai.gctrl.rotate-logs.timer
echo "Installed daily log rotation: ai.gctrl.rotate-logs"
}
# ── Detect OS ────────────────────────────────────────────────────────────────
case "$(uname -s)" in
Darwin)
echo "Detected macOS — installing launchd service..."
install_launchd
install_log_rotation_launchd
;;
Linux)
echo "Detected Linux — installing systemd user service..."
install_systemd
install_log_rotation_systemd
;;
*)
echo "Unsupported OS: $(uname -s)"
echo "Run manually: node $BOT_JS"
exit 1
;;
esac
{
"type": "module"
}
#!/bin/bash
# ^ Ground Ctrl — Log rotation
# Rotates log files in ~/.gctrl/logs/ when they exceed the size threshold.
# Uses copytruncate: copies to .1, truncates original in-place so launchd's
# open file descriptor continues working.
# Designed to run daily via launchd/systemd timer.
LOG_DIR="$HOME/.gctrl/logs"
MAX_SIZE=$((5 * 1024 * 1024)) # 5MB
[ -d "$LOG_DIR" ] || exit 0
for logfile in "$LOG_DIR"/*.log; do
[ -f "$logfile" ] || continue
# Portable file size: macOS stat -f%z, Linux stat -c%s
size=$(stat -f%z "$logfile" 2>/dev/null || stat -c%s "$logfile" 2>/dev/null || echo 0)
if [ "$size" -gt "$MAX_SIZE" ]; then
cp "$logfile" "${logfile}.1"
: > "$logfile"
fi
done
#!/usr/bin/env node
/**
* Send a Telegram message. Used for notifications, reminders, alerts,
* or proactive messaging from tasks.
*
* Usage: node send.js "Your message here"
*/
import { loadEnv } from './env.js';
loadEnv();
// --- Main ---
const BOT_TOKEN = process.env.TELEGRAM_BOT_TOKEN;
const CHAT_ID = (process.env.ALLOWED_USERS || '').split(',')[0]?.trim();
if (!BOT_TOKEN) {
console.error('Error: TELEGRAM_BOT_TOKEN not set in .env');
process.exit(1);
}
if (!CHAT_ID) {
console.error('Error: ALLOWED_USERS not set in .env');
process.exit(1);
}
let message = process.argv[2];
if (!message) {
console.error('Usage: send.js "message"');
process.exit(1);
}
// Strip backslash escapes that LLMs sometimes add
message = message.replace(/\\([!.?\-,:;=+#>()\[\]{}])/g, '$1');
const res = await fetch(`https://api.telegram.org/bot${BOT_TOKEN}/sendMessage`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
chat_id: CHAT_ID,
text: message,
parse_mode: 'HTML',
}),
});
if (!res.ok) {
const body = await res.text();
console.error(`Telegram API error: ${res.status} ${body}`);
process.exit(1);
}
console.log(`Sent: ${message.slice(0, 100)}${message.length > 100 ? '...' : ''}`);

Telegram Bot — Setup

IMPORTANT: Follow these steps exactly in order. Ask the user one question at a time.

Step 1: Install Telegram

Ask the user: "Do you have Telegram installed? If not, download it from https://telegram.org — it's available on iOS, Android, Mac, Windows, and Linux."

Wait for the user to confirm they have Telegram before continuing.

Step 2: Create a Telegram bot

Walk the user through creating a bot:

  1. Tell them: "Open Telegram and search for @BotFather — this is Telegram's official tool for creating bots."
  2. Tell them: "Send /newbot to BotFather."
  3. Tell them: "BotFather will ask for a display name — you can use something like Ground Ctrl or whatever you prefer."
  4. Tell them: "Next it will ask for a username. This must end in bot — for example my_groundctrl_bot."
  5. Tell them: "BotFather will reply with your bot token. It looks like 123456789:ABCdefGHIjklMNOpqrsTUVwxyz. Copy that token."

Ask the user: "What's the bot token from BotFather?"

Save the token for later steps.

Step 3: Get your Telegram user ID

Tell the user: "Now search for @userinfobot in Telegram and send it any message. It will reply with your user ID — a number like 123456789."

Ask the user: "What's your Telegram user ID?"

Save the user ID for later steps.

Step 4: Configure

Write a .env file in this skill's directory (same directory as this SETUP.md file):

TELEGRAM_BOT_TOKEN=<bot-token>
ALLOWED_USERS=<user-id>

Step 5: Test the connection

Run:

node <this-skill-dir>/scripts/send.js "Hello from Ground Ctrl!"

The user should receive the message in Telegram from their bot. If it works, continue. If not, double-check the bot token and user ID.

Step 6: Install the background service

Tell the user: "Installing the Telegram bot as a background service — this starts automatically when your computer boots so you can message your AI agent anytime."

Run:

bash <this-skill-dir>/scripts/install.sh

Step 7: Clean up

Delete this SETUP.md file:

rm <this-skill-dir>/SETUP.md

Tell the user: "Telegram is set up and ready. You can message your bot anytime to chat with your AI agent."

name description author version
telegram
Telegram bot — chat with Claude from your phone. Send messages, photos, voice notes, and documents. Also send outbound notifications.
gctrl
0.1.0

Telegram Bot

Chat with Claude CLI via Telegram. Supports text, voice messages, photos, and documents with streaming responses.

First-Run Setup

IMPORTANT: If SETUP.md exists in this skill's directory, read it and follow its instructions immediately — do not explain or ask, just start the setup flow.

Voice Support (Optional)

If mlx_whisper or whisper is in PATH, voice messages will be transcribed automatically. If not available, voice messages will prompt the user to send text instead.

Usage

Send a Message (from tasks/skills)

node <skill-dir>/scripts/send.js "Your message here"

Messages use HTML parse mode. Supported tags: <b>, <i>, <code>, <pre>, <a href="">, <s>.

Run Bot Interactively

node <skill-dir>/scripts/bot.js

Service Management

bash <skill-dir>/scripts/install.sh     # Install as service
tail -f ~/.gctrl/logs/telegram.log       # Watch logs

macOS:

launchctl kickstart gui/$(id -u)/ai.gctrl.telegram    # Start
launchctl bootout gui/$(id -u)/ai.gctrl.telegram      # Stop

Guidelines

  • Messages sent via send.js use HTML parse mode — use Telegram HTML tags, not markdown
  • Do NOT escape special characters with backslashes in send.js messages
  • Keep messages concise — this is a mobile chat interface
  • The bot streams Claude's response in real-time with live message editing
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment