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:
21
.env.example
Normal file
21
.env.example
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# Telegram API credentials (https://my.telegram.org/apps)
|
||||||
|
TELEGRAM_API_ID=12345
|
||||||
|
TELEGRAM_API_HASH=0123456789abcdef0123456789abcdef
|
||||||
|
|
||||||
|
# Bot token from @BotFather
|
||||||
|
TELEGRAM_BOT_TOKEN=123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11
|
||||||
|
|
||||||
|
# Comma-separated Telegram user IDs allowed to use the bot
|
||||||
|
ADMIN_IDS=123456789,987654321
|
||||||
|
|
||||||
|
# Working directory for Claude Code tasks
|
||||||
|
WORK_DIR=/
|
||||||
|
|
||||||
|
# Optional: Anthropic API key. Leave empty to use logged-in Claude Code CLI credentials.
|
||||||
|
ANTHROPIC_API_KEY=
|
||||||
|
|
||||||
|
# Optional: path to the sessions persistence file (default: sessions.json next to the bot)
|
||||||
|
#SESSIONS_FILE=/opt/claude-tg-bot/sessions.json
|
||||||
|
|
||||||
|
# Required for bypassPermissions when running as root
|
||||||
|
IS_SANDBOX=1
|
||||||
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
venv/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.env
|
||||||
|
sessions.json
|
||||||
|
*.session
|
||||||
|
*.session-journal
|
||||||
61
README.md
Normal file
61
README.md
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
# Claude Server Admin Telegram Bot
|
||||||
|
|
||||||
|
Telegram-бот (Python, [kurigram](https://github.com/KurimuzonAkuma/pyrogram)), который выполняет задачи по администрированию сервера через Claude Code с `bypassPermissions`, используя [Claude Agent SDK](https://docs.claude.com/en/api/agent-sdk/python).
|
||||||
|
|
||||||
|
- Доступ только для админов — Telegram user ID из env-переменной `ADMIN_IDS`.
|
||||||
|
- Контекст беседы сохраняется на чат (resume сессий Claude), `/new` сбрасывает.
|
||||||
|
- Стриминг прогресса: бот показывает вызовы инструментов по мере работы.
|
||||||
|
- Устанавливается как systemd-сервис, автозапуск после ребута.
|
||||||
|
|
||||||
|
⚠️ **Безопасность**: бот даёт полный контроль над сервером любому из `ADMIN_IDS`. Храните токен бота в секрете и не добавляйте лишних админов.
|
||||||
|
|
||||||
|
## Требования на сервере
|
||||||
|
|
||||||
|
- Linux с systemd
|
||||||
|
- Python 3.10+
|
||||||
|
- Node.js 18+ и Claude Code CLI (`npm install -g @anthropic-ai/claude-code`)
|
||||||
|
- Аутентификация Claude: `ANTHROPIC_API_KEY` в `.env` **или** залогиненный CLI (`claude login` под root)
|
||||||
|
|
||||||
|
## Установка
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone <repo> && cd claude-as-server-admin-py
|
||||||
|
sudo bash deploy/install.sh
|
||||||
|
sudo nano /etc/claude-tg-bot/.env # заполнить креды
|
||||||
|
sudo systemctl restart claude-tg-bot
|
||||||
|
```
|
||||||
|
|
||||||
|
Проверка:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
systemctl status claude-tg-bot
|
||||||
|
journalctl -u claude-tg-bot -f
|
||||||
|
```
|
||||||
|
|
||||||
|
## Конфигурация (`/etc/claude-tg-bot/.env`)
|
||||||
|
|
||||||
|
| Переменная | Описание |
|
||||||
|
|---|---|
|
||||||
|
| `TELEGRAM_API_ID` / `TELEGRAM_API_HASH` | с https://my.telegram.org/apps |
|
||||||
|
| `TELEGRAM_BOT_TOKEN` | от @BotFather |
|
||||||
|
| `ADMIN_IDS` | user ID через запятую (узнать: @userinfobot) |
|
||||||
|
| `WORK_DIR` | рабочая директория Claude Code, по умолчанию `/` |
|
||||||
|
| `ANTHROPIC_API_KEY` | опционально, если не используется `claude login` |
|
||||||
|
| `IS_SANDBOX=1` | обязательно для `bypassPermissions` под root |
|
||||||
|
|
||||||
|
## Команды бота
|
||||||
|
|
||||||
|
- любой текст — задача для Claude Code
|
||||||
|
- `/new` — новая беседа (сброс контекста)
|
||||||
|
- `/model` — выбор модели (Fable 5 / Opus 4.8 / Sonnet 5 / Haiku 4.5) и уровня ризонинга (low…max) через inline-кнопки, настройки сохраняются per-chat
|
||||||
|
- `/status` — статус задачи и сессии
|
||||||
|
- `/stop` — отменить текущую задачу
|
||||||
|
- `/help` — справка
|
||||||
|
|
||||||
|
## Локальный запуск (разработка)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m venv venv && venv/bin/pip install -r requirements.txt
|
||||||
|
cp .env.example .env # заполнить; WORK_DIR лучше указать тестовую папку
|
||||||
|
venv/bin/python -m bot.main
|
||||||
|
```
|
||||||
0
bot/__init__.py
Normal file
0
bot/__init__.py
Normal file
87
bot/claude_runner.py
Normal file
87
bot/claude_runner.py
Normal 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
33
bot/config.py
Normal 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
256
bot/main.py
Normal 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
42
bot/sessions.py
Normal 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
83
bot/telegram_ui.py
Normal 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)
|
||||||
17
deploy/claude-tg-bot.service
Normal file
17
deploy/claude-tg-bot.service
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Claude Server Admin Telegram Bot
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=root
|
||||||
|
WorkingDirectory=/opt/claude-tg-bot
|
||||||
|
EnvironmentFile=/etc/claude-tg-bot/.env
|
||||||
|
Environment=IS_SANDBOX=1
|
||||||
|
ExecStart=/opt/claude-tg-bot/venv/bin/python -m bot.main
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
65
deploy/install.sh
Normal file
65
deploy/install.sh
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Installs the Claude Server Admin Telegram Bot as a systemd service.
|
||||||
|
# Run as root on the target Linux server, from the project root:
|
||||||
|
# sudo bash deploy/install.sh
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
INSTALL_DIR=/opt/claude-tg-bot
|
||||||
|
CONFIG_DIR=/etc/claude-tg-bot
|
||||||
|
SERVICE_NAME=claude-tg-bot
|
||||||
|
PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
|
||||||
|
if [[ $EUID -ne 0 ]]; then
|
||||||
|
echo "ERROR: run as root (sudo bash deploy/install.sh)" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "==> Checking prerequisites"
|
||||||
|
missing=0
|
||||||
|
if ! command -v python3 >/dev/null; then
|
||||||
|
echo "ERROR: python3 not found. Install it (e.g. apt install python3 python3-venv)." >&2
|
||||||
|
missing=1
|
||||||
|
fi
|
||||||
|
if ! command -v node >/dev/null; then
|
||||||
|
echo "ERROR: node not found. The Claude Agent SDK needs Node.js 18+." >&2
|
||||||
|
echo " e.g. curl -fsSL https://deb.nodesource.com/setup_22.x | bash - && apt install -y nodejs" >&2
|
||||||
|
missing=1
|
||||||
|
fi
|
||||||
|
if ! command -v claude >/dev/null; then
|
||||||
|
echo "WARNING: claude CLI not found. Install it: npm install -g @anthropic-ai/claude-code" >&2
|
||||||
|
echo " (newer claude-agent-sdk versions bundle the CLI; if the bot fails to start, install it)" >&2
|
||||||
|
fi
|
||||||
|
[[ $missing -eq 0 ]] || exit 1
|
||||||
|
|
||||||
|
echo "==> Copying project to $INSTALL_DIR"
|
||||||
|
mkdir -p "$INSTALL_DIR"
|
||||||
|
cp -r "$PROJECT_DIR/bot" "$PROJECT_DIR/requirements.txt" "$INSTALL_DIR/"
|
||||||
|
|
||||||
|
echo "==> Creating virtualenv and installing dependencies"
|
||||||
|
if [[ ! -d "$INSTALL_DIR/venv" ]]; then
|
||||||
|
python3 -m venv "$INSTALL_DIR/venv"
|
||||||
|
fi
|
||||||
|
"$INSTALL_DIR/venv/bin/pip" install --upgrade pip -q
|
||||||
|
"$INSTALL_DIR/venv/bin/pip" install -r "$INSTALL_DIR/requirements.txt" -q
|
||||||
|
|
||||||
|
echo "==> Setting up config in $CONFIG_DIR"
|
||||||
|
mkdir -p "$CONFIG_DIR"
|
||||||
|
if [[ ! -f "$CONFIG_DIR/.env" ]]; then
|
||||||
|
cp "$PROJECT_DIR/.env.example" "$CONFIG_DIR/.env"
|
||||||
|
chmod 600 "$CONFIG_DIR/.env"
|
||||||
|
echo ""
|
||||||
|
echo "!! Created $CONFIG_DIR/.env from template."
|
||||||
|
echo "!! Fill in TELEGRAM_API_ID, TELEGRAM_API_HASH, TELEGRAM_BOT_TOKEN, ADMIN_IDS,"
|
||||||
|
echo "!! then run: systemctl restart $SERVICE_NAME"
|
||||||
|
echo ""
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "==> Installing systemd service"
|
||||||
|
cp "$PROJECT_DIR/deploy/$SERVICE_NAME.service" "/etc/systemd/system/$SERVICE_NAME.service"
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable --now "$SERVICE_NAME"
|
||||||
|
|
||||||
|
echo "==> Done"
|
||||||
|
systemctl --no-pager status "$SERVICE_NAME" || true
|
||||||
|
echo ""
|
||||||
|
echo "Logs: journalctl -u $SERVICE_NAME -f"
|
||||||
4
requirements.txt
Normal file
4
requirements.txt
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
kurigram
|
||||||
|
tgcrypto
|
||||||
|
claude-agent-sdk
|
||||||
|
python-dotenv
|
||||||
Reference in New Issue
Block a user