Telegram bot running server-admin tasks via Claude Agent SDK

kurigram bot with admin check by user IDs from env, per-chat Claude
session resume, streaming progress, /model command for model and
reasoning effort selection, and systemd deploy script.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-09 12:40:44 +02:00
commit c6c3a70b9a
12 changed files with 676 additions and 0 deletions

0
bot/__init__.py Normal file
View File

87
bot/claude_runner.py Normal file
View File

@@ -0,0 +1,87 @@
"""Runs tasks through the Claude Agent SDK and yields progress events."""
import json
from dataclasses import dataclass
from typing import AsyncIterator
from claude_agent_sdk import (
AssistantMessage,
ClaudeAgentOptions,
ClaudeSDKClient,
ResultMessage,
TextBlock,
ToolUseBlock,
)
from . import config
TOOL_INPUT_PREVIEW_LEN = 80
@dataclass
class ToolEvent:
name: str
brief: str
@dataclass
class TextEvent:
text: str
@dataclass
class DoneEvent:
session_id: str | None
result_text: str | None
is_error: bool
num_turns: int
cost_usd: float | None
def _tool_brief(block: ToolUseBlock) -> str:
"""A short one-line preview of the tool input for the progress message."""
inp = block.input or {}
for key in ("command", "file_path", "path", "pattern", "url", "prompt", "description"):
if key in inp and isinstance(inp[key], str):
brief = inp[key]
break
else:
brief = json.dumps(inp, ensure_ascii=False)
brief = " ".join(brief.split())
if len(brief) > TOOL_INPUT_PREVIEW_LEN:
brief = brief[: TOOL_INPUT_PREVIEW_LEN - 1] + "…"
return brief
async def run_task(
prompt: str,
resume_session_id: str | None,
model: str | None = None,
effort: str | None = None,
) -> AsyncIterator[ToolEvent | TextEvent | DoneEvent]:
"""Execute one task, yielding progress events and finally a DoneEvent."""
options = ClaudeAgentOptions(
permission_mode="bypassPermissions",
cwd=config.WORK_DIR,
resume=resume_session_id,
system_prompt=config.SYSTEM_PROMPT,
model=model,
effort=effort,
)
async with ClaudeSDKClient(options=options) as client:
await client.query(prompt)
async for message in client.receive_response():
if isinstance(message, AssistantMessage):
for block in message.content:
if isinstance(block, ToolUseBlock):
yield ToolEvent(name=block.name, brief=_tool_brief(block))
elif isinstance(block, TextBlock) and block.text.strip():
yield TextEvent(text=block.text)
elif isinstance(message, ResultMessage):
yield DoneEvent(
session_id=message.session_id,
result_text=message.result,
is_error=message.is_error,
num_turns=message.num_turns,
cost_usd=message.total_cost_usd,
)

33
bot/config.py Normal file
View File

@@ -0,0 +1,33 @@
"""Configuration loaded from environment variables (.env supported via python-dotenv)."""
import os
from pathlib import Path
from dotenv import load_dotenv
load_dotenv()
PROJECT_DIR = Path(__file__).resolve().parent.parent
TELEGRAM_API_ID = int(os.environ["TELEGRAM_API_ID"])
TELEGRAM_API_HASH = os.environ["TELEGRAM_API_HASH"]
TELEGRAM_BOT_TOKEN = os.environ["TELEGRAM_BOT_TOKEN"]
ADMIN_IDS = [
int(part)
for part in os.environ.get("ADMIN_IDS", "").replace(" ", "").split(",")
if part
]
if not ADMIN_IDS:
raise RuntimeError("ADMIN_IDS is empty — set at least one Telegram user ID")
WORK_DIR = os.environ.get("WORK_DIR", "/")
SESSIONS_FILE = Path(os.environ.get("SESSIONS_FILE", PROJECT_DIR / "sessions.json"))
SYSTEM_PROMPT = os.environ.get(
"SYSTEM_PROMPT",
"You are the administrator of this Linux server, operating via a Telegram bot. "
"Execute the tasks you are given carefully. Answer concisely: the reply is shown "
"in a Telegram chat. Reply in the same language the user writes in.",
)

256
bot/main.py Normal file
View File

@@ -0,0 +1,256 @@
"""Entry point: kurigram bot that runs admin tasks via Claude Code."""
import asyncio
import logging
from pyrogram import Client, filters
from pyrogram.enums import ChatAction, ParseMode
from pyrogram.errors import RPCError
from pyrogram.types import (
CallbackQuery,
InlineKeyboardButton,
InlineKeyboardMarkup,
Message,
)
from . import claude_runner, config
from .claude_runner import DoneEvent, TextEvent, ToolEvent
from .sessions import ChatStore
from .telegram_ui import ProgressReporter, send_long_message
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
log = logging.getLogger("claude-tg-bot")
app = Client(
"claude_admin_bot",
api_id=config.TELEGRAM_API_ID,
api_hash=config.TELEGRAM_API_HASH,
bot_token=config.TELEGRAM_BOT_TOKEN,
workdir=str(config.PROJECT_DIR),
)
chats = ChatStore(config.SESSIONS_FILE)
running_tasks: dict[int, asyncio.Task] = {}
admin = filters.user(config.ADMIN_IDS)
MODELS: list[tuple[str, str | None]] = [
("Fable 5", "claude-fable-5"),
("Opus 4.8", "claude-opus-4-8"),
("Sonnet 5", "claude-sonnet-5"),
("Haiku 4.5", "claude-haiku-4-5"),
("По умолчанию", None),
]
EFFORTS: list[tuple[str, str | None]] = [
("low", "low"),
("medium", "medium"),
("high", "high"),
("xhigh", "xhigh"),
("max", "max"),
("По умолчанию", None),
]
HELP_TEXT = (
"**Claude Server Admin Bot**\n\n"
"Любое текстовое сообщение — задача для Claude Code на сервере.\n"
"Контекст беседы сохраняется между сообщениями.\n\n"
"Команды:\n"
"/new — начать новую беседу (сбросить контекст)\n"
"/model — выбрать модель и уровень ризонинга\n"
"/status — статус текущей задачи и сессии\n"
"/stop — отменить выполняющуюся задачу\n"
"/help — эта справка\n\n"
f"Рабочая директория: `{config.WORK_DIR}`"
)
def _model_keyboard(chat_id: int) -> InlineKeyboardMarkup:
current_model = chats.get(chat_id, "model")
current_effort = chats.get(chat_id, "effort")
def label(name: str, value: str | None, current: str | None) -> str:
return f"✅ {name}" if value == current else name
rows = [[InlineKeyboardButton("— Модель —", callback_data="noop")]]
for i in range(0, len(MODELS), 2):
rows.append([
InlineKeyboardButton(
label(name, value, current_model), callback_data=f"model:{value or ''}"
)
for name, value in MODELS[i : i + 2]
])
rows.append([InlineKeyboardButton("— Уровень ризонинга —", callback_data="noop")])
for i in range(0, len(EFFORTS), 3):
rows.append([
InlineKeyboardButton(
label(name, value, current_effort), callback_data=f"effort:{value or ''}"
)
for name, value in EFFORTS[i : i + 3]
])
return InlineKeyboardMarkup(rows)
MODEL_TEXT = (
"**Настройки модели**\n\n"
"Выберите модель и уровень ризонинга (effort) для задач в этом чате.\n"
"«По умолчанию» — использовать настройки Claude Code на сервере.\n\n"
"_Примечание: xhigh и max не поддерживаются на Haiku 4.5._"
)
@app.on_message(filters.command(["start", "help"]) & admin)
async def cmd_help(_, message: Message):
await message.reply_text(HELP_TEXT, parse_mode=ParseMode.MARKDOWN)
@app.on_message(filters.command("new") & admin)
async def cmd_new(_, message: Message):
if chats.clear_session(message.chat.id):
await message.reply_text("🆕 Контекст сброшен, начинаем новую беседу.")
else:
await message.reply_text("Контекста и не было — беседа новая.")
@app.on_message(filters.command("model") & admin)
async def cmd_model(_, message: Message):
await message.reply_text(
MODEL_TEXT,
parse_mode=ParseMode.MARKDOWN,
reply_markup=_model_keyboard(message.chat.id),
)
@app.on_callback_query(admin)
async def on_settings_callback(_, query: CallbackQuery):
data = query.data or ""
if data == "noop":
await query.answer()
return
key, _, raw = data.partition(":")
if key not in ("model", "effort"):
await query.answer()
return
value = raw or None
chat_id = query.message.chat.id
chats.set(chat_id, key, value)
shown = value or "по умолчанию"
await query.answer(f"{'Модель' if key == 'model' else 'Ризонинг'}: {shown}")
try:
await query.message.edit_reply_markup(_model_keyboard(chat_id))
except RPCError:
pass
@app.on_message(filters.command("status") & admin)
async def cmd_status(_, message: Message):
chat_id = message.chat.id
task = running_tasks.get(chat_id)
state = "🔄 задача выполняется" if task and not task.done() else "💤 бот свободен"
session_id = chats.get(chat_id, "session_id")
session_line = f"`{session_id}`" if session_id else "нет (новая беседа)"
model = chats.get(chat_id, "model") or "по умолчанию"
effort = chats.get(chat_id, "effort") or "по умолчанию"
await message.reply_text(
f"{state}\nСессия: {session_line}\n"
f"Модель: `{model}`\nРизонинг: `{effort}`\n"
f"Рабочая директория: `{config.WORK_DIR}`",
parse_mode=ParseMode.MARKDOWN,
)
@app.on_message(filters.command("stop") & admin)
async def cmd_stop(_, message: Message):
task = running_tasks.get(message.chat.id)
if task and not task.done():
task.cancel()
await message.reply_text("🛑 Останавливаю задачу…")
else:
await message.reply_text("Сейчас ничего не выполняется.")
@app.on_message(filters.text & ~filters.command(["start", "help", "new", "model", "status", "stop"]) & admin)
async def on_task(client: Client, message: Message):
chat_id = message.chat.id
existing = running_tasks.get(chat_id)
if existing and not existing.done():
await message.reply_text(
"⚠️ Задача уже выполняется. Дождитесь завершения или отмените: /stop"
)
return
running_tasks[chat_id] = asyncio.create_task(_execute(client, message))
@app.on_message()
async def on_other(_, message: Message):
user = message.from_user
if user and user.id in config.ADMIN_IDS:
await message.reply_text("Я понимаю только текстовые сообщения.")
return
log.warning(
"Ignoring message from unauthorized user id=%s username=%s",
user.id if user else "?",
user.username if user else "?",
)
async def _execute(client: Client, message: Message):
chat_id = message.chat.id
await client.send_chat_action(chat_id, ChatAction.TYPING)
progress = ProgressReporter(await message.reply_text("⏳ Работаю…"))
done: DoneEvent | None = None
try:
async for event in claude_runner.run_task(
message.text,
chats.get(chat_id, "session_id"),
model=chats.get(chat_id, "model"),
effort=chats.get(chat_id, "effort"),
):
if isinstance(event, ToolEvent):
progress.add_line(f"🔧 {event.name}: {event.brief}")
await progress.flush()
elif isinstance(event, TextEvent):
first_line = event.text.strip().splitlines()[0]
progress.add_line(f"💬 {first_line[:100]}")
await progress.flush()
elif isinstance(event, DoneEvent):
done = event
except asyncio.CancelledError:
await progress.delete()
await message.reply_text("🛑 Задача отменена.")
return
except Exception:
log.exception("Task failed in chat %s", chat_id)
await progress.delete()
await message.reply_text(
"❌ Ошибка при выполнении задачи, подробности в логах бота."
)
return
finally:
running_tasks.pop(chat_id, None)
await progress.delete()
if done is None:
await message.reply_text("❌ Claude завершился без результата.")
return
if done.session_id:
chats.set(chat_id, "session_id", done.session_id)
status_icon = "❌" if done.is_error else "✅"
cost = f" · ${done.cost_usd:.4f}" if done.cost_usd else ""
session_short = f" · session {done.session_id[:8]}…" if done.session_id else ""
footer = f"\n\n{status_icon} {done.num_turns} шагов{cost}{session_short}"
body = done.result_text or "(пустой ответ)"
await send_long_message(message, body + footer)
def main():
log.info("Starting bot, admins: %s, work dir: %s", config.ADMIN_IDS, config.WORK_DIR)
app.run()
if __name__ == "__main__":
main()

42
bot/sessions.py Normal file
View File

@@ -0,0 +1,42 @@
"""Per-chat state persistence: session_id, model and effort in a JSON file."""
import json
from pathlib import Path
class ChatStore:
def __init__(self, path: Path):
self._path = path
self._chats: dict[str, dict] = {}
if path.exists():
try:
data = json.loads(path.read_text(encoding="utf-8"))
# migrate old flat format {chat_id: session_id}
self._chats = {
k: (v if isinstance(v, dict) else {"session_id": v})
for k, v in data.items()
}
except (json.JSONDecodeError, OSError):
self._chats = {}
def get(self, chat_id: int, key: str) -> str | None:
return self._chats.get(str(chat_id), {}).get(key)
def set(self, chat_id: int, key: str, value: str | None) -> None:
chat = self._chats.setdefault(str(chat_id), {})
if value is None:
chat.pop(key, None)
else:
chat[key] = value
self._save()
def clear_session(self, chat_id: int) -> bool:
removed = self._chats.get(str(chat_id), {}).pop("session_id", None) is not None
if removed:
self._save()
return removed
def _save(self) -> None:
self._path.write_text(
json.dumps(self._chats, indent=2), encoding="utf-8"
)

83
bot/telegram_ui.py Normal file
View File

@@ -0,0 +1,83 @@
"""Telegram output helpers: throttled progress edits, long-message splitting."""
import asyncio
import time
from pyrogram.enums import ParseMode
from pyrogram.errors import FloodWait, MessageNotModified, RPCError
from pyrogram.types import Message
TELEGRAM_MESSAGE_LIMIT = 4096
EDIT_INTERVAL_SECONDS = 2.0
PROGRESS_MAX_LINES = 15
class ProgressReporter:
"""Edits a single "working..." message with throttling as the task progresses."""
def __init__(self, message: Message):
self._message = message
self._lines: list[str] = []
self._last_edit = 0.0
self._last_text = message.text or ""
self._lock = asyncio.Lock()
def add_line(self, line: str) -> None:
self._lines.append(line)
def _render(self) -> str:
shown = self._lines[-PROGRESS_MAX_LINES:]
hidden = len(self._lines) - len(shown)
header = "⏳ Работаю…"
if hidden > 0:
header += f" (скрыто {hidden} строк)"
text = header + "\n" + "\n".join(shown)
return text[:TELEGRAM_MESSAGE_LIMIT]
async def flush(self, force: bool = False) -> None:
async with self._lock:
now = time.monotonic()
if not force and now - self._last_edit < EDIT_INTERVAL_SECONDS:
return
text = self._render()
if text == self._last_text:
return
try:
await self._message.edit_text(text, parse_mode=ParseMode.DISABLED)
self._last_text = text
self._last_edit = time.monotonic()
except MessageNotModified:
self._last_text = text
except FloodWait as e:
self._last_edit = time.monotonic() + e.value
except RPCError:
pass
async def delete(self) -> None:
try:
await self._message.delete()
except RPCError:
pass
def split_text(text: str, limit: int = TELEGRAM_MESSAGE_LIMIT) -> list[str]:
"""Split text into chunks under the Telegram limit, preferring line breaks."""
chunks = []
while len(text) > limit:
cut = text.rfind("\n", 0, limit)
if cut < limit // 2:
cut = limit
chunks.append(text[:cut])
text = text[cut:].lstrip("\n")
if text:
chunks.append(text)
return chunks
async def send_long_message(message: Message, text: str) -> None:
"""Reply with text of any length, falling back to plain text on Markdown errors."""
for chunk in split_text(text):
try:
await message.reply_text(chunk, parse_mode=ParseMode.MARKDOWN)
except RPCError:
await message.reply_text(chunk, parse_mode=ParseMode.DISABLED)