Files
mypointvpn/src/App/bot/interface.py
T
protokeyandClaude Opus 5.5 a8d1f286d1 feat: docker-серверы - приложения в ~/apps под управлением бота
serverus: DockerHost - установка Docker и ingress Caddy, deploy приложения
с проверкой compose и caddy.conf до изменений и пересозданием только
изменившихся сервисов, status, control, logs, remove. nftables
перезапускается только при изменении конфига, Docker следом
восстанавливает свои правила; release восстанавливает конфиг Debian
вместо остановки nftables.

Telegram: прием документов и отправка файлов, раздача файлов своего
Bot API сервера через nginx (telegram-files).

App: общая модель Managed для Point и Platform, DockerApp и версии
файлов AppVersion, разбор архивов (uploads), сценарии платформы
(platforms), задачи и меню docker-серверов в боте.

Проверено на стенде Debian 13 с local_certs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 20:27:05 +04:00

767 lines
26 KiB
Python

import ipaddress
import re
import serverus
from Telegram import api as bot_api
from Telegram.interfaces import Interface, command, register, state, text
from .. import fields, platforms, services, tasks, uploads
from ..models import AppVersion, Credentials, DockerApp, Platform, Point, Server
from . import texts
# состояния диалога
DRAFT = 'draft'
DRAFT_SERVER = 'draft.server'
DRAFT_MASK = 'draft.mask'
DRAFT_DOMAIN = 'draft.domain'
DRAFT_PROXY = 'draft.proxy'
SELECT = 'select'
POINT = 'point'
POINT_RELEASE = 'point.release'
POINT_REINSTALL = 'point.reinstall'
POINT_DOMAIN_EDIT = 'point.domain'
POINT_PROXY_EDIT = 'point.proxy'
POINT_KEYS = 'point.keys'
POINT_KEY_ADD = 'point.keys.add'
POINT_KEY_REVOKE = 'point.keys.revoke'
DOCKER_ADD = 'docker.add'
DOCKER_SELECT = 'docker.select'
PLATFORM = 'platform'
PLATFORM_RELEASE = 'platform.release'
PLATFORM_REINSTALL = 'platform.reinstall'
APP_NEW = 'platform.app.new'
APP = 'app'
APP_UPLOAD = 'app.upload'
APP_CONFIRM = 'app.confirm'
APP_REMOVE = 'app.remove'
SERVICE = 'app.service'
DRAFT_CREDENTIALS = {DRAFT_SERVER: 'server', DRAFT_MASK: 'mask'}
DOMAIN_RE = re.compile(r'^(?=.{1,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$')
HTML = 'HTML'
def parse_credentials(value: str) -> Credentials | None:
"""'ip:password' -> Credentials. Пароль может содержать двоеточия."""
address, _, password = value.strip().partition(':')
try:
address = str(ipaddress.ip_address(address))
except ValueError:
return None
return Credentials(address, password) if password else None
def is_domain(value: str) -> bool:
return bool(DOMAIN_RE.match(value))
def is_proxy_url(value: str) -> bool:
try:
serverus.parse_proxy(value)
except ValueError:
return False
return True
@register
class MyPointInterface(Interface):
title = 'MyPoint VPN'
# главное меню
@command('start')
@text(texts.MAIN_MENU, texts.CANCEL)
def main_menu(self, ctx):
ctx.reset()
points = list(Point.objects.owned_by(ctx.user))
platform_list = list(Platform.objects.owned_by(ctx.user))
lines = [texts.points_list(points)] if points else []
lines += [texts.platforms_list(platform_list)] if platform_list else []
ctx.reply('\n'.join(lines) or texts.GREETING, keyboard=self.main_keyboard(ctx), parse_mode=HTML)
def main_keyboard(self, ctx):
return texts.main_keyboard(
Point.objects.owned_by(ctx.user).exists(), Platform.objects.owned_by(ctx.user).exists(),
)
@text(texts.FAQ)
def faq(self, ctx):
ctx.reply(texts.FAQ_TEXT, parse_mode=HTML)
@text(texts.HELP)
def help(self, ctx):
ctx.reply(texts.HELP_TEXT, parse_mode=HTML)
def fallback(self, ctx):
ctx.reply(texts.FALLBACK)
# добавление сервера: черновик живет в данных чата до "установить"
@text(texts.ADD_SERVER)
def add_server(self, ctx):
ctx.reset()
ctx.data['draft'] = {}
self.show_draft(ctx)
def show_draft(self, ctx):
ctx.state = DRAFT
ctx.reply(texts.draft_card(ctx.data['draft']), keyboard=texts.DRAFT_KEYBOARD, parse_mode=HTML)
@text(texts.SERVER, state=DRAFT)
def ask_server(self, ctx):
self.ask(ctx, DRAFT_SERVER, texts.CREDENTIALS_PROMPT)
@text(texts.MASK, state=DRAFT)
def ask_mask(self, ctx):
self.ask(ctx, DRAFT_MASK, texts.CREDENTIALS_PROMPT, [[texts.CANCEL, texts.DELETE]])
@text(texts.DELETE, state=DRAFT_MASK)
def delete_mask(self, ctx):
ctx.data['draft'].pop('mask', None)
self.show_draft(ctx)
@text(texts.DOMAIN, state=DRAFT)
def ask_domain(self, ctx):
self.ask(ctx, DRAFT_DOMAIN, texts.DOMAIN_PROMPT, [[texts.CANCEL, texts.DELETE]])
@text(texts.PROXY, state=DRAFT)
def ask_proxy(self, ctx):
self.ask(ctx, DRAFT_PROXY, texts.PROXY_PROMPT, [[texts.CF_WARP, texts.CANCEL, texts.DELETE]])
def ask(self, ctx, new_state: str, prompt: str, keyboard=texts.CANCEL_KEYBOARD):
ctx.state = new_state
ctx.reply(prompt, keyboard=keyboard, parse_mode=HTML)
@text(texts.CANCEL, state=DRAFT_SERVER)
@text(texts.CANCEL, state=DRAFT_MASK)
@text(texts.CANCEL, state=DRAFT_DOMAIN)
@text(texts.CANCEL, state=DRAFT_PROXY)
def back_to_draft(self, ctx):
self.show_draft(ctx)
@state(DRAFT_SERVER, DRAFT_MASK)
def set_credentials(self, ctx):
# в сообщении пароль root, в чате ему не место
ctx.delete_message()
credentials = parse_credentials(ctx.text)
if credentials is None:
return ctx.reply(texts.CREDENTIALS_INVALID, parse_mode=HTML)
draft = ctx.data['draft']
key = DRAFT_CREDENTIALS[ctx.state]
other = draft.get('mask' if key == 'server' else 'server', {})
if other.get('address') == credentials.address:
return ctx.reply(texts.ADDRESS_DUPLICATE)
if Server.objects.is_address_taken(credentials.address):
return ctx.reply(texts.ADDRESS_TAKEN.format(address=credentials.address))
draft[key] = {'address': credentials.address, 'password': credentials.password}
self.show_draft(ctx)
@state(DRAFT_DOMAIN)
def set_domain(self, ctx):
domain = ctx.text.strip().lower().rstrip('.')
if not is_domain(domain):
return ctx.reply(texts.DOMAIN_INVALID)
ctx.data['draft']['domain'] = domain
self.show_draft(ctx)
@text(texts.DELETE, state=DRAFT_DOMAIN)
def delete_domain(self, ctx):
ctx.data['draft']['domain'] = ''
self.show_draft(ctx)
@state(DRAFT_PROXY)
def set_proxy(self, ctx):
# в прокси может быть пароль
ctx.delete_message()
proxy = ctx.text.strip()
if not is_proxy_url(proxy):
return ctx.reply(texts.PROXY_INVALID, parse_mode=HTML)
ctx.data['draft']['proxy'] = proxy
self.show_draft(ctx)
@text(texts.CF_WARP, state=DRAFT_PROXY)
def set_cf_warp(self, ctx):
ctx.data['draft']['proxy'] = Point.CF_WARP
self.show_draft(ctx)
@text(texts.DELETE, state=DRAFT_PROXY)
def delete_proxy(self, ctx):
ctx.data['draft']['proxy'] = ''
self.show_draft(ctx)
@text(texts.INSTALL, state=DRAFT)
def install(self, ctx):
draft = ctx.data['draft']
if not draft.get('server'):
return ctx.reply(texts.DRAFT_INCOMPLETE)
if draft.get('mask') and not draft.get('domain'):
return ctx.reply(texts.MASK_WITHOUT_DOMAIN)
point = Point.objects.create_point(
ctx.user,
panel=Credentials(**draft['server']),
mask=Credentials(**draft['mask']) if draft.get('mask') else None,
domain=draft.get('domain', ''),
outbound_proxy=draft.get('proxy', ''),
)
tasks.start(tasks.install_point, point, Point.Status.INSTALLING, ctx.chat)
ctx.reset()
ctx.reply(texts.INSTALL_STARTED, keyboard=self.main_keyboard(ctx))
# управление сервером
@text(texts.MANAGE)
def ask_point(self, ctx):
ctx.reset()
points = Point.objects.owned_by(ctx.user)
self.ask(ctx, SELECT, f'{texts.SELECT_PROMPT}\n{texts.points_list(points)}')
@state(SELECT)
def select_point(self, ctx):
point = Point.objects.owned_by(ctx.user).find_by_name(ctx.text.strip())
if point is None:
return ctx.reply(texts.POINT_NOT_FOUND)
self.show_point(ctx, point)
@text(texts.SERVER_MENU)
def server_menu(self, ctx):
"""Кнопка из уведомления: последняя открытая точка или последняя созданная."""
point = self.current_point(ctx) or Point.objects.owned_by(ctx.user).find_last()
if point is None:
return self.main_menu(ctx)
self.show_point(ctx, point)
def current_point(self, ctx) -> Point | None:
point_id = ctx.data.get('point')
return Point.objects.owned_by(ctx.user).find_by_pk(point_id) if point_id else None
def show_point(self, ctx, point: Point):
ctx.reset()
ctx.state = POINT
ctx.data['point'] = point.pk
ctx.reply(texts.point_card(point), keyboard=texts.POINT_KEYBOARD, parse_mode=HTML)
def idle_point(self, ctx) -> Point | None:
"""Текущая точка, если с ней можно работать. Иначе отвечает пользователю и возвращает None."""
point = self.current_point(ctx)
if point is None:
self.main_menu(ctx)
elif point.is_busy:
ctx.reply(texts.POINT_BUSY.format(status=point.get_status_display()))
return None
return point
@text(texts.RELEASE, state=POINT)
def ask_release(self, ctx):
if self.idle_point(ctx):
self.ask(ctx, POINT_RELEASE, texts.RELEASE_CONFIRM, [[texts.YES, texts.NO]])
@text(texts.YES, state=POINT_RELEASE)
def release(self, ctx):
point = self.idle_point(ctx)
if point is None:
return
tasks.start(tasks.release_point, point, Point.Status.RELEASING, ctx.chat)
ctx.reset()
ctx.reply(texts.RELEASE_STARTED, keyboard=self.main_keyboard(ctx))
@text(texts.REINSTALL, state=POINT)
def ask_reinstall(self, ctx):
if self.idle_point(ctx):
self.ask(ctx, POINT_REINSTALL, texts.REINSTALL_PROMPT, [[texts.CANCEL, texts.USE_OLD]])
@state(POINT_REINSTALL)
def reinstall_with_password(self, ctx):
ctx.delete_message()
credentials = parse_credentials(ctx.text)
if credentials is None:
return ctx.reply(texts.CREDENTIALS_INVALID, parse_mode=HTML)
point = self.idle_point(ctx)
if point is None:
return
if point.servers.find_by_address(credentials.address) is None:
return ctx.reply(texts.REINSTALL_UNKNOWN_ADDRESS.format(address=credentials.address))
self.start_reinstall(ctx, point, credentials.address, credentials.password)
@text(texts.USE_OLD, state=POINT_REINSTALL)
def reinstall_with_old_passwords(self, ctx):
point = self.idle_point(ctx)
if point:
self.start_reinstall(ctx, point)
def start_reinstall(self, ctx, point: Point, address: str | None = None, password: str | None = None):
tasks.start(tasks.reinstall_point, point, Point.Status.INSTALLING, ctx.chat, address, password)
ctx.reset()
ctx.reply(texts.REINSTALL_STARTED, keyboard=self.main_keyboard(ctx))
@text(texts.NO, state=POINT_RELEASE)
@text(texts.CANCEL, state=POINT_RELEASE)
@text(texts.CANCEL, state=POINT_REINSTALL)
@text(texts.CANCEL, state=POINT_DOMAIN_EDIT)
@text(texts.CANCEL, state=POINT_PROXY_EDIT)
def back_to_point(self, ctx):
point = self.current_point(ctx)
if point is None:
return self.main_menu(ctx)
self.show_point(ctx, point)
# домен и прокси готовой точки: применяются в фоне, панель тем временем работает с прежними настройками
@text(texts.POINT_DOMAIN, state=POINT)
def ask_point_domain(self, ctx):
point = self.ready_point(ctx)
if point:
keyboard = texts.CANCEL_KEYBOARD if point.mask else [[texts.CANCEL, texts.DELETE]]
self.ask(ctx, POINT_DOMAIN_EDIT, texts.domain_prompt(point), keyboard)
@state(POINT_DOMAIN_EDIT)
def change_domain(self, ctx):
domain = ctx.text.strip().lower().rstrip('.')
if not is_domain(domain):
return ctx.reply(texts.DOMAIN_INVALID)
point = self.ready_point(ctx)
if point:
self.start_change(ctx, point, tasks.set_domain, domain)
@text(texts.DELETE, state=POINT_DOMAIN_EDIT)
def delete_point_domain(self, ctx):
point = self.ready_point(ctx)
if point is None:
return
if point.mask:
return ctx.reply(texts.DOMAIN_REQUIRED_BY_MASK)
if not point.domain:
return self.show_point(ctx, point)
self.start_change(ctx, point, tasks.set_domain, '')
@text(texts.POINT_PROXY, state=POINT)
def ask_point_proxy(self, ctx):
point = self.ready_point(ctx)
if point:
self.ask(ctx, POINT_PROXY_EDIT, texts.proxy_prompt(point), [[texts.CF_WARP, texts.CANCEL, texts.DELETE]])
@state(POINT_PROXY_EDIT)
def change_proxy(self, ctx):
# в прокси может быть пароль
ctx.delete_message()
proxy = ctx.text.strip()
if not is_proxy_url(proxy):
return ctx.reply(texts.PROXY_INVALID, parse_mode=HTML)
self.start_proxy_change(ctx, proxy)
@text(texts.CF_WARP, state=POINT_PROXY_EDIT)
def change_proxy_to_warp(self, ctx):
self.start_proxy_change(ctx, Point.CF_WARP)
@text(texts.DELETE, state=POINT_PROXY_EDIT)
def delete_point_proxy(self, ctx):
self.start_proxy_change(ctx, '')
def start_proxy_change(self, ctx, proxy: str):
point = self.ready_point(ctx)
if point:
self.start_change(ctx, point, tasks.set_outbound_proxy, fields.encrypt(proxy) if proxy else '')
def start_change(self, ctx, point: Point, actor, value: str):
tasks.start(actor, point, Point.Status.UPDATING, ctx.chat, value)
ctx.reset()
ctx.reply(texts.CHANGE_STARTED, keyboard=self.main_keyboard(ctx))
# ключи
def ready_point(self, ctx) -> Point | None:
"""Текущая точка, если на ее панели можно выпускать ключи. Иначе отвечает пользователю и возвращает None."""
point = self.idle_point(ctx)
if point is not None and (point.status != Point.Status.READY or not point.panel_inbound_id):
ctx.reply(texts.POINT_NOT_READY)
return None
return point
@text(texts.CLIENTS, state=POINT)
@text(texts.CLIENTS, state=POINT_KEYS)
@text(texts.CANCEL, state=POINT_KEY_ADD)
@text(texts.CANCEL, state=POINT_KEY_REVOKE)
def show_keys(self, ctx):
point = self.ready_point(ctx)
if point is None:
return
ctx.state = POINT_KEYS
keys = [(key, services.key_traffic(key)) for key in point.keys.all()]
ctx.reply(texts.keys_list(point, keys), keyboard=texts.KEYS_KEYBOARD, parse_mode=HTML)
@state(POINT_KEYS)
def show_key(self, ctx):
point = self.ready_point(ctx)
if point is None:
return
key = point.keys.find_by_name(ctx.text.strip().lower())
if key is None:
return ctx.reply(texts.KEY_NOT_FOUND)
ctx.reply(texts.key_card(key, services.key_traffic(key)), parse_mode=HTML)
@text(texts.ADD_KEY, state=POINT_KEYS)
def ask_key_name(self, ctx):
if self.ready_point(ctx):
self.ask(ctx, POINT_KEY_ADD, texts.KEY_NAME_PROMPT)
@state(POINT_KEY_ADD)
def add_key(self, ctx):
point = self.ready_point(ctx)
if point is None:
return
try:
key = services.issue_key(point, ctx.text.strip().lower())
except ValueError as error:
return ctx.reply(str(error))
except serverus.XuiApiError as error:
return ctx.reply(texts.PANEL_ERROR.format(error=error))
ctx.state = POINT_KEYS
ctx.reply(texts.key_card(key, None), keyboard=texts.KEYS_KEYBOARD, parse_mode=HTML)
@text(texts.REVOKE_KEY, state=POINT_KEYS)
def ask_revoke_key(self, ctx):
if self.ready_point(ctx):
self.ask(ctx, POINT_KEY_REVOKE, texts.KEY_REVOKE_PROMPT)
@state(POINT_KEY_REVOKE)
def revoke_key(self, ctx):
point = self.ready_point(ctx)
if point is None:
return
key = point.keys.find_by_name(ctx.text.strip().lower())
if key is None:
return ctx.reply(texts.KEY_NOT_FOUND)
try:
services.revoke_key(key)
except serverus.XuiApiError as error:
return ctx.reply(texts.PANEL_ERROR.format(error=error))
ctx.reply(texts.KEY_REVOKED.format(name=key.name))
self.show_keys(ctx)
# docker-серверы
@text(texts.ADD_DOCKER)
def ask_docker_server(self, ctx):
ctx.reset()
self.ask(ctx, DOCKER_ADD, texts.DOCKER_CREDENTIALS_PROMPT)
@text(texts.CANCEL, state=DOCKER_ADD)
@text(texts.CANCEL, state=DOCKER_SELECT)
def cancel_docker(self, ctx):
self.main_menu(ctx)
@state(DOCKER_ADD)
def add_docker_server(self, ctx):
# в сообщении пароль root, в чате ему не место
ctx.delete_message()
credentials = parse_credentials(ctx.text)
if credentials is None:
return ctx.reply(texts.CREDENTIALS_INVALID, parse_mode=HTML)
if Server.objects.is_address_taken(credentials.address):
return ctx.reply(texts.ADDRESS_TAKEN.format(address=credentials.address))
platform = Platform.objects.create_platform(ctx.user, credentials)
tasks.start(tasks.install_platform, platform, Platform.Status.INSTALLING, ctx.chat)
ctx.reset()
ctx.reply(texts.DOCKER_INSTALL_STARTED, keyboard=self.main_keyboard(ctx))
@text(texts.DOCKER_SERVERS)
def ask_platform(self, ctx):
ctx.reset()
platform_list = Platform.objects.owned_by(ctx.user)
self.ask(ctx, DOCKER_SELECT, f'{texts.PLATFORM_SELECT_PROMPT}\n{texts.platforms_list(platform_list)}')
@state(DOCKER_SELECT)
def select_platform(self, ctx):
platform = Platform.objects.owned_by(ctx.user).find_by_name(ctx.text.strip())
if platform is None:
return ctx.reply(texts.PLATFORM_NOT_FOUND)
self.show_platform(ctx, platform)
@text(texts.PLATFORM_MENU)
def platform_menu(self, ctx):
"""Кнопка из уведомления: последний открытый docker-сервер или последний созданный."""
platform = self.current_platform(ctx) or Platform.objects.owned_by(ctx.user).find_last()
if platform is None:
return self.main_menu(ctx)
self.show_platform(ctx, platform)
def current_platform(self, ctx) -> Platform | None:
platform_id = ctx.data.get('platform')
return Platform.objects.owned_by(ctx.user).find_by_pk(platform_id) if platform_id else None
def show_platform(self, ctx, platform: Platform):
ctx.reset()
ctx.state = PLATFORM
ctx.data['platform'] = platform.pk
ctx.reply(texts.platform_card(platform), keyboard=texts.PLATFORM_KEYBOARD, parse_mode=HTML)
def idle_platform(self, ctx, ready: bool = True) -> Platform | None:
"""Текущий docker-сервер, если с ним можно работать. Иначе отвечает пользователю и возвращает None.
ready - сервер должен быть установлен."""
platform = self.current_platform(ctx)
if platform is None:
self.main_menu(ctx)
elif platform.is_busy:
ctx.reply(texts.POINT_BUSY.format(status=platform.get_status_display()))
return None
elif ready and platform.status != Platform.Status.READY:
ctx.reply(texts.PLATFORM_NOT_READY)
return None
return platform
@text(texts.CHECK, state=PLATFORM)
def check_platform(self, ctx):
platform = self.idle_platform(ctx)
if platform:
tasks.refresh_platform.send(platform.pk, ctx.chat.pk)
ctx.reply(texts.CHECK_STARTED)
@text(texts.RELEASE, state=PLATFORM)
def ask_platform_release(self, ctx):
if self.idle_platform(ctx, ready=False):
self.ask(ctx, PLATFORM_RELEASE, texts.PLATFORM_RELEASE_CONFIRM, [[texts.YES, texts.NO]])
@text(texts.YES, state=PLATFORM_RELEASE)
def release_platform(self, ctx):
platform = self.idle_platform(ctx, ready=False)
if platform is None:
return
tasks.start(tasks.release_platform, platform, Platform.Status.RELEASING, ctx.chat)
ctx.reset()
ctx.reply(texts.RELEASE_STARTED, keyboard=self.main_keyboard(ctx))
@text(texts.REINSTALL, state=PLATFORM)
def ask_platform_reinstall(self, ctx):
if self.idle_platform(ctx, ready=False):
self.ask(ctx, PLATFORM_REINSTALL, texts.PLATFORM_REINSTALL_PROMPT, [[texts.CANCEL, texts.USE_OLD]])
@state(PLATFORM_REINSTALL)
def reinstall_platform_with_password(self, ctx):
ctx.delete_message()
credentials = parse_credentials(ctx.text)
if credentials is None:
return ctx.reply(texts.CREDENTIALS_INVALID, parse_mode=HTML)
platform = self.idle_platform(ctx, ready=False)
if platform is None:
return
if platform.server.address != credentials.address:
return ctx.reply(texts.REINSTALL_UNKNOWN_ADDRESS.format(address=credentials.address))
self.start_platform_reinstall(ctx, platform, fields.encrypt(credentials.password))
@text(texts.USE_OLD, state=PLATFORM_REINSTALL)
def reinstall_platform_with_old_password(self, ctx):
platform = self.idle_platform(ctx, ready=False)
if platform:
self.start_platform_reinstall(ctx, platform, '')
def start_platform_reinstall(self, ctx, platform: Platform, encrypted_password: str):
tasks.start(tasks.reinstall_platform, platform, Platform.Status.INSTALLING, ctx.chat, encrypted_password)
ctx.reset()
ctx.reply(texts.REINSTALL_STARTED, keyboard=self.main_keyboard(ctx))
@text(texts.NO, state=PLATFORM_RELEASE)
@text(texts.CANCEL, state=PLATFORM_RELEASE)
@text(texts.CANCEL, state=PLATFORM_REINSTALL)
@text(texts.CANCEL, state=APP_NEW)
def back_to_platform(self, ctx):
platform = self.current_platform(ctx)
if platform is None:
return self.main_menu(ctx)
self.show_platform(ctx, platform)
# приложения
@state(PLATFORM)
def select_app(self, ctx):
platform = self.current_platform(ctx)
if platform is None:
return self.main_menu(ctx)
app = platform.apps.find_by_name(ctx.text.strip())
if app is None:
return ctx.reply(texts.APP_NOT_FOUND)
self.show_app(ctx, app)
def current_app(self, ctx) -> DockerApp | None:
platform = self.current_platform(ctx)
app_id = ctx.data.get('app')
return platform.apps.filter(pk=app_id).first() if platform and app_id else None
def show_app(self, ctx, app: DockerApp):
ctx.state = APP
ctx.data['app'] = app.pk
ctx.data.pop('service', None)
ctx.reply(texts.app_card(app), keyboard=texts.APP_KEYBOARD, parse_mode=HTML)
@text(texts.APP_MENU)
@text(texts.CANCEL, state=APP_REMOVE)
def app_menu(self, ctx):
app = self.current_app(ctx)
if app is None:
return self.platform_menu(ctx)
self.show_app(ctx, app)
@text(texts.NEW_APP, state=PLATFORM)
def ask_app_name(self, ctx):
if self.idle_platform(ctx):
self.ask(ctx, APP_NEW, texts.APP_NAME_PROMPT)
@state(APP_NEW)
def create_app(self, ctx):
platform = self.idle_platform(ctx)
if platform is None:
return
try:
app = platforms.create_app(platform, ctx.text.strip())
except ValueError as error:
return ctx.reply(str(error))
ctx.data['app'] = app.pk
ctx.data['new_app'] = True
self.ask_upload(ctx, AppVersion.Kind.FULL)
@text(texts.UPLOAD_UPDATE, state=APP)
def ask_update(self, ctx):
app = self.current_app(ctx)
if app is None:
return self.platform_menu(ctx)
if app.current_version() is None:
return ctx.reply(texts.APP_NOT_DEPLOYED)
self.ask_upload(ctx, AppVersion.Kind.UPDATE)
@text(texts.UPLOAD_FULL, state=APP)
def ask_full(self, ctx):
if self.current_app(ctx) is None:
return self.platform_menu(ctx)
self.ask_upload(ctx, AppVersion.Kind.FULL)
def ask_upload(self, ctx, kind: str):
ctx.data['kind'] = kind
prompt = texts.UPLOAD_UPDATE_PROMPT if kind == AppVersion.Kind.UPDATE else texts.UPLOAD_FULL_PROMPT
self.ask(ctx, APP_UPLOAD, prompt)
@state(APP_UPLOAD)
def receive_upload(self, ctx):
app = self.current_app(ctx)
if app is None:
return self.platform_menu(ctx)
document = ctx.document
if document is None:
return ctx.reply(texts.UPLOAD_NOT_DOCUMENT)
try:
content = ctx.download_document(uploads.ARCHIVE_MAX)
upload = platforms.prepare_upload(app, document.name, content, ctx.data.get('kind', AppVersion.Kind.UPDATE))
except (bot_api.ApiError, uploads.UploadError) as error:
return ctx.reply(texts.upload_failed(error), parse_mode=HTML)
self.show_upload(ctx, upload)
def show_upload(self, ctx, upload: platforms.Upload):
if upload.version is None:
# ошибки - ждем исправленные файлы, без изменений - назад к приложению
ctx.reply(texts.upload_report(upload), parse_mode=HTML)
if not upload.errors:
self.app_menu(ctx)
return
ctx.state = APP_CONFIRM
ctx.data['version'] = upload.version.pk
ctx.reply(texts.upload_report(upload), keyboard=texts.CONFIRM_KEYBOARD, parse_mode=HTML)
@text(texts.CANCEL, state=APP_UPLOAD)
@text(texts.CANCEL, state=APP_CONFIRM)
def cancel_upload(self, ctx):
app = self.current_app(ctx)
version_id = ctx.data.pop('version', None)
if app and version_id:
app.versions.filter(pk=version_id, state=AppVersion.State.DRAFT).delete()
if app and ctx.data.pop('new_app', False):
platforms.discard_new_app(app)
return self.platform_menu(ctx)
self.app_menu(ctx)
@text(texts.APPLY, state=APP_CONFIRM)
def apply_upload(self, ctx):
platform = self.idle_platform(ctx)
app = self.current_app(ctx)
if platform is None or app is None:
return
version = app.versions.filter(pk=ctx.data.get('version'), state=AppVersion.State.DRAFT).first()
if version is None:
ctx.reply(texts.VERSION_GONE)
return self.app_menu(ctx)
tasks.start(tasks.deploy_version, platform, Platform.Status.UPDATING, ctx.chat, version.pk)
ctx.data.pop('version', None)
ctx.data.pop('new_app', None)
ctx.state = APP
ctx.reply(texts.APPLY_STARTED, keyboard=texts.APP_KEYBOARD)
@text(texts.ROLLBACK, state=APP)
def rollback(self, ctx):
app = self.current_app(ctx)
if app is None:
return self.platform_menu(ctx)
try:
upload = platforms.prepare_rollback(app)
except ValueError:
return ctx.reply(texts.ROLLBACK_NONE)
self.show_upload(ctx, upload)
# управление приложением и сервисом
@text(texts.START, state=APP)
@text(texts.STOP, state=APP)
@text(texts.RESTART, state=APP)
@text(texts.PULL, state=APP)
@text(texts.START, state=SERVICE)
@text(texts.STOP, state=SERVICE)
@text(texts.RESTART, state=SERVICE)
def control_app(self, ctx):
platform = self.idle_platform(ctx)
app = self.current_app(ctx)
if platform is None or app is None:
return
if app.current_version() is None:
return ctx.reply(texts.APP_NOT_DEPLOYED)
service = ctx.data.get('service', '') if ctx.state == SERVICE else ''
tasks.start(tasks.control_app, platform, Platform.Status.UPDATING, ctx.chat, app.pk, texts.ACTIONS[ctx.text], service)
ctx.reply(texts.OPERATION_STARTED)
@text(texts.LOGS, state=APP)
@text(texts.LOGS, state=SERVICE)
def send_logs(self, ctx):
app = self.current_app(ctx)
if app is None:
return self.platform_menu(ctx)
service = ctx.data.get('service', '') if ctx.state == SERVICE else ''
tasks.send_logs.send(app.pk, service, ctx.chat.pk)
ctx.reply(texts.LOGS_STARTED)
@state(APP)
def select_service(self, ctx):
app = self.current_app(ctx)
if app is None:
return self.platform_menu(ctx)
service = ctx.text.strip()
services = app.platform.app_status(app.name).get('services', {})
if not services:
return ctx.reply(texts.NO_SNAPSHOT)
if service not in services:
return ctx.reply(texts.APP_NOT_FOUND)
ctx.state = SERVICE
ctx.data['service'] = service
ctx.reply(texts.service_card(app, service), keyboard=texts.SERVICE_KEYBOARD, parse_mode=HTML)
@text(texts.REMOVE_APP, state=APP)
def ask_remove_app(self, ctx):
if self.idle_platform(ctx) and self.current_app(ctx):
self.ask(ctx, APP_REMOVE, texts.REMOVE_APP_CONFIRM, texts.REMOVE_APP_KEYBOARD)
@text(texts.REMOVE_KEEP_DATA, state=APP_REMOVE)
@text(texts.REMOVE_WITH_DATA, state=APP_REMOVE)
def remove_app(self, ctx):
platform = self.idle_platform(ctx)
app = self.current_app(ctx)
if platform is None or app is None:
return
volumes = ctx.text == texts.REMOVE_WITH_DATA
tasks.start(tasks.remove_app, platform, Platform.Status.UPDATING, ctx.chat, app.pk, volumes)
ctx.data.pop('app', None)
ctx.state = PLATFORM
ctx.reply(texts.OPERATION_STARTED, keyboard=texts.PLATFORM_KEYBOARD)