Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1 +1,8 @@
resources/
__pycache__/
*.pyc
*.egg-info/
dist/
build/
.venv/
*.db
201 changes: 201 additions & 0 deletions bin/telecoder
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
#!/usr/bin/env bash
set -euo pipefail

# TeleCoder — run a coding agent on your own VPS.
# A thin CLI that composes: claude CLI + git + tmux + sqlite3.

VERSION="0.1.0"

# Resolve lib directory
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
LIB_DIR="${SCRIPT_DIR}/../lib"

# Load config
if [ -f "${TELECODER_CONFIG:-}" ]; then
# shellcheck source=/dev/null
source "$TELECODER_CONFIG"
elif [ -f "$HOME/.config/telecoder/config.sh" ]; then
source "$HOME/.config/telecoder/config.sh"
elif [ -f "/etc/telecoder/config.sh" ]; then
source "/etc/telecoder/config.sh"
fi

# Defaults
TELECODER_DATA="${TELECODER_DATA:-$HOME/.telecoder}"
TELECODER_RUNTIME="${TELECODER_RUNTIME:-claude}"
TELECODER_BRANCH_PREFIX="${TELECODER_BRANCH_PREFIX:-telecoder/}"
TELECODER_AUTO_PUSH="${TELECODER_AUTO_PUSH:-false}"

# Load library modules
source "${LIB_DIR}/db.sh"
source "${LIB_DIR}/session.sh"
source "${LIB_DIR}/verify.sh"

# --- Commands ---

cmd_init() {
mkdir -p "${TELECODER_DATA}"/{workspaces,logs}
db_init
echo "TeleCoder initialized at ${TELECODER_DATA}"

local config_dest="$HOME/.config/telecoder/config.sh"
if [ ! -f "$config_dest" ]; then
mkdir -p "$(dirname "$config_dest")"
local example="${SCRIPT_DIR}/../config.example.sh"
if [ -f "$example" ]; then
cp "$example" "$config_dest"
echo "Config template copied to $config_dest"
fi
fi

echo ""
echo "Next: edit $config_dest and set TELECODER_RUNTIME if needed."
}

cmd_create() {
local repo_url="" repo_path="" branch="" test_cmd="" lint_cmd=""

while [ $# -gt 0 ]; do
case "$1" in
--repo-url) repo_url="$2"; shift 2 ;;
--repo-path) repo_path="$2"; shift 2 ;;
--branch) branch="$2"; shift 2 ;;
--test-cmd) test_cmd="$2"; shift 2 ;;
--lint-cmd) lint_cmd="$2"; shift 2 ;;
*) echo "Unknown option: $1" >&2; return 1 ;;
esac
done

local id
id=$(session_create "$repo_url" "$repo_path" "$branch" "$test_cmd" "$lint_cmd")
echo "Session created: $id"
}

cmd_run() {
if [ $# -lt 2 ]; then
echo "Usage: telecoder run <session-id> <prompt>" >&2
return 1
fi
local id="$1"; shift
local prompt="$*"
session_run "$id" "$prompt"
}

cmd_stop() {
if [ $# -lt 1 ]; then
echo "Usage: telecoder stop <session-id>" >&2
return 1
fi
session_stop "$1"
}

cmd_list() {
local status_filter=""
if [ $# -gt 0 ] && [ "$1" = "--status" ] && [ $# -gt 1 ]; then
status_filter="$2"
fi
session_list "$status_filter"
}

cmd_inspect() {
if [ $# -lt 1 ]; then
echo "Usage: telecoder inspect <session-id>" >&2
return 1
fi
session_inspect "$1"
}

cmd_logs() {
if [ $# -lt 1 ]; then
echo "Usage: telecoder logs <session-id> [--stream stderr] [--tail N]" >&2
return 1
fi
local id="$1"; shift
local stream="stdout" tail_n="100"
while [ $# -gt 0 ]; do
case "$1" in
--stream) stream="$2"; shift 2 ;;
--tail) tail_n="$2"; shift 2 ;;
*) shift ;;
esac
done
session_logs "$id" "$stream" "$tail_n"
}

cmd_attach() {
if [ $# -lt 1 ]; then
echo "Usage: telecoder attach <session-id>" >&2
return 1
fi
session_attach "$1"
}

cmd_verify() {
if [ $# -lt 1 ]; then
echo "Usage: telecoder verify <session-id>" >&2
return 1
fi
session_verify "$1"
}

cmd_delete() {
if [ $# -lt 1 ]; then
echo "Usage: telecoder delete <session-id>" >&2
return 1
fi
echo -n "Delete session $1? [y/N] "
read -r confirm
if [ "$confirm" = "y" ] || [ "$confirm" = "Y" ]; then
session_delete "$1"
fi
}

cmd_help() {
cat <<EOF
telecoder v${VERSION} — run a coding agent on your own VPS

Usage: telecoder <command> [options]

Commands:
init First-time setup
create [options] Create a new session
--repo-url <url> Git repo to clone
--repo-path <path> Local repo path
--branch <name> Branch to create/use
--test-cmd <cmd> Test command
--lint-cmd <cmd> Lint command
run <id> <prompt> Run a prompt in a session
stop <id> Stop a running session
list [--status <s>] List sessions
inspect <id> Show session details
logs <id> [--stream stderr] View session logs
attach <id> Attach to tmux session
verify <id> Run tests/lint
delete <id> Delete a session
help Show this help

Session runs in tmux. Close your terminal — it keeps going.
Come back with: telecoder attach <id>
EOF
}

# --- Main dispatch ---

cmd="${1:-help}"
shift || true

case "$cmd" in
init) cmd_init "$@" ;;
create) cmd_create "$@" ;;
run) cmd_run "$@" ;;
stop) cmd_stop "$@" ;;
list|ls) cmd_list "$@" ;;
inspect) cmd_inspect "$@" ;;
logs) cmd_logs "$@" ;;
attach) cmd_attach "$@" ;;
verify) cmd_verify "$@" ;;
delete) cmd_delete "$@" ;;
help|-h|--help) cmd_help ;;
version|--version) echo "telecoder v${VERSION}" ;;
*) echo "Unknown command: $cmd. Run 'telecoder help'." >&2; exit 1 ;;
esac
Empty file added bot/__init__.py
Empty file.
171 changes: 171 additions & 0 deletions bot/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
# /// script
# requires-python = ">=3.11"
# dependencies = [
# "python-telegram-bot>=21.0",
# ]
# ///
"""Telegram bot for TeleCoder — relay messages straight to Claude Code."""

import logging
import os

from telegram import Update
from telegram.ext import (
ApplicationBuilder,
CommandHandler,
ContextTypes,
MessageHandler,
filters,
)

from bot import telecoder

logging.basicConfig(level=logging.INFO)
log = logging.getLogger("telecoder-bot")

# Optional: restrict to specific Telegram user IDs for security
ALLOWED_USERS: set[int] = set()
_allowed = os.environ.get("TELECODER_TG_ALLOWED_USERS", "")
if _allowed:
ALLOWED_USERS = {int(uid.strip()) for uid in _allowed.split(",") if uid.strip()}


def _authorized(update: Update) -> bool:
if not ALLOWED_USERS:
return True # no restriction configured
return update.effective_user and update.effective_user.id in ALLOWED_USERS


# ── State: one active session per Telegram chat ──────────────────────
# chat_id -> session_id
_sessions: dict[int, str] = {}


async def cmd_start(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
await update.message.reply_text(
"Send me what to do. It runs immediately.\n\n"
"Commands:\n"
"/new <repo-url> — create a session for a repo\n"
"/status — check current session\n"
"/logs — last 30 lines of output\n"
"/stop — stop current session\n\n"
"Or just type what you want done."
)


async def cmd_new(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
if not _authorized(update):
return
args = ctx.args
repo_url = args[0] if args else None
chat_id = update.effective_chat.id

await update.message.reply_text("Creating session…")
try:
sid = telecoder.create(repo_url=repo_url)
_sessions[chat_id] = sid.strip()
await update.message.reply_text(f"Session `{sid.strip()}` ready. Send me what to do.", parse_mode="Markdown")
except Exception as e:
await update.message.reply_text(f"Failed: {e}")


async def cmd_status(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
if not _authorized(update):
return
chat_id = update.effective_chat.id
sid = _sessions.get(chat_id)
if not sid:
await update.message.reply_text("No active session. Use /new or just send a message.")
return
try:
info = telecoder.inspect(sid)
await update.message.reply_text(f"```\n{info}\n```", parse_mode="Markdown")
except Exception as e:
await update.message.reply_text(f"Error: {e}")


async def cmd_logs(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
if not _authorized(update):
return
chat_id = update.effective_chat.id
sid = _sessions.get(chat_id)
if not sid:
await update.message.reply_text("No active session.")
return
try:
output = telecoder.logs(sid)
# Telegram has a 4096 char limit
if len(output) > 4000:
output = "…" + output[-4000:]
await update.message.reply_text(f"```\n{output}\n```", parse_mode="Markdown")
except Exception as e:
await update.message.reply_text(f"Error: {e}")


async def cmd_stop(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
if not _authorized(update):
return
chat_id = update.effective_chat.id
sid = _sessions.get(chat_id)
if not sid:
await update.message.reply_text("No active session.")
return
try:
telecoder.stop(sid)
await update.message.reply_text(f"Stopped `{sid}`.", parse_mode="Markdown")
except Exception as e:
await update.message.reply_text(f"Error: {e}")


async def handle_message(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
"""Plain English message → create session if needed → run immediately."""
if not _authorized(update):
return

chat_id = update.effective_chat.id
text = update.message.text.strip()

if not text:
return

sid = _sessions.get(chat_id)

# Auto-create a session if none exists
if not sid:
try:
sid = telecoder.create().strip()
_sessions[chat_id] = sid
except Exception as e:
await update.message.reply_text(f"Could not create session: {e}")
return

# Send the message as-is to Claude Code
await update.message.reply_text("On it.")
try:
result = telecoder.run(sid, text)
await update.message.reply_text(result)
except Exception as e:
await update.message.reply_text(f"Error: {e}")


def main() -> None:
token = os.environ.get("TELEGRAM_BOT_TOKEN")
if not token:
raise SystemExit("Set TELEGRAM_BOT_TOKEN env var")

app = ApplicationBuilder().token(token).build()

app.add_handler(CommandHandler("start", cmd_start))
app.add_handler(CommandHandler("help", cmd_start))
app.add_handler(CommandHandler("new", cmd_new))
app.add_handler(CommandHandler("status", cmd_status))
app.add_handler(CommandHandler("logs", cmd_logs))
app.add_handler(CommandHandler("stop", cmd_stop))
app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, handle_message))

log.info("Bot started — polling for messages")
app.run_polling()


if __name__ == "__main__":
main()
Loading