chore: интерфесы бота
This commit is contained in:
1 parent
5f53cadf89
commit
6d322f36a9
42 files changed
+1731
-111
No files matched your search
@@ -0,0 +1,308 @@
|
||||
import ipaddress
|
||||
import re
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from Telegram.interfaces import Interface, command, register, state, text
|
||||
|
||||
from .. import services, tasks
|
||||
from ..models import Point, Server
|
||||
from . import texts
|
||||
|
||||
# состояния диалога
|
||||
DRAFT = 'draft'
|
||||
DRAFT_SERVER = 'draft.server'
|
||||
DRAFT_MASK = 'draft.mask'
|
||||
DRAFT_DOMAIN = 'draft.domain'
|
||||
DRAFT_CLOUDFLARE = 'draft.cloudflare'
|
||||
DRAFT_PROXY = 'draft.proxy'
|
||||
SELECT = 'select'
|
||||
POINT = 'point'
|
||||
POINT_RELEASE = 'point.release'
|
||||
POINT_REINSTALL = 'point.reinstall'
|
||||
|
||||
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}$')
|
||||
PROXY_SCHEMES = ('http', 'https', 'socks5')
|
||||
HTML = 'HTML'
|
||||
|
||||
|
||||
def parse_credentials(value: str) -> services.Credentials | None:
|
||||
"""'ip:password' -> Credentials. Пароль может содержать двоеточия."""
|
||||
address, _, password = value.strip().partition(':')
|
||||
try:
|
||||
address = str(ipaddress.ip_address(address))
|
||||
except ValueError:
|
||||
return None
|
||||
return services.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:
|
||||
url = urlsplit(value)
|
||||
return url.scheme in PROXY_SCHEMES and bool(url.hostname) and bool(url.port)
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
@register
|
||||
class MyPointInterface(Interface):
|
||||
title = 'MyPoint VPN'
|
||||
|
||||
# главное меню
|
||||
|
||||
@command('start')
|
||||
@text(texts.MAIN_MENU, texts.CANCEL)
|
||||
def main_menu(self, ctx):
|
||||
ctx.reset()
|
||||
points = list(ctx.user.points.order_by('created_at'))
|
||||
if points:
|
||||
ctx.reply(texts.points_list(points), keyboard=texts.MAIN_KEYBOARD_WITH_POINTS, parse_mode=HTML)
|
||||
else:
|
||||
ctx.reply(texts.GREETING, keyboard=texts.MAIN_KEYBOARD)
|
||||
|
||||
@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)
|
||||
|
||||
@text(texts.DOMAIN, state=DRAFT)
|
||||
def ask_domain(self, ctx):
|
||||
self.ask(ctx, DRAFT_DOMAIN, texts.DOMAIN_PROMPT, [[texts.CANCEL, texts.DELETE]])
|
||||
|
||||
@text(texts.CLOUDFLARE, state=DRAFT)
|
||||
def ask_cloudflare(self, ctx):
|
||||
self.ask(ctx, DRAFT_CLOUDFLARE, texts.CLOUDFLARE_PROMPT, [[texts.ENABLE, texts.DISABLE, texts.CANCEL]])
|
||||
|
||||
@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_CLOUDFLARE)
|
||||
@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.filter(address=credentials.address).exists():
|
||||
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)
|
||||
|
||||
@text(texts.ENABLE, state=DRAFT_CLOUDFLARE)
|
||||
def enable_cloudflare(self, ctx):
|
||||
ctx.data['draft']['cloudflare'] = True
|
||||
self.show_draft(ctx)
|
||||
|
||||
@text(texts.DISABLE, state=DRAFT_CLOUDFLARE)
|
||||
def disable_cloudflare(self, ctx):
|
||||
ctx.data['draft']['cloudflare'] = False
|
||||
self.show_draft(ctx)
|
||||
|
||||
@state(DRAFT_CLOUDFLARE)
|
||||
def repeat_cloudflare(self, ctx):
|
||||
self.ask_cloudflare(ctx)
|
||||
|
||||
@state(DRAFT_PROXY)
|
||||
def set_proxy(self, ctx):
|
||||
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') and draft.get('mask')):
|
||||
return ctx.reply(texts.DRAFT_INCOMPLETE)
|
||||
if draft.get('cloudflare') and not draft.get('domain'):
|
||||
return ctx.reply(texts.CLOUDFLARE_WITHOUT_DOMAIN)
|
||||
point = services.create_point(
|
||||
ctx.user,
|
||||
panel=services.Credentials(**draft['server']),
|
||||
mask=services.Credentials(**draft['mask']),
|
||||
domain=draft.get('domain', ''),
|
||||
cloudflare=draft.get('cloudflare', False),
|
||||
outbound_proxy=draft.get('proxy', ''),
|
||||
)
|
||||
tasks.install_point.send(point.pk, ctx.chat.pk)
|
||||
ctx.reset()
|
||||
ctx.reply(texts.INSTALL_STARTED, keyboard=texts.MAIN_KEYBOARD_WITH_POINTS)
|
||||
|
||||
# управление сервером
|
||||
|
||||
@text(texts.MANAGE)
|
||||
def ask_point(self, ctx):
|
||||
ctx.reset()
|
||||
points = ctx.user.points.order_by('created_at')
|
||||
self.ask(ctx, SELECT, f'{texts.SELECT_PROMPT}\n{texts.points_list(points)}')
|
||||
|
||||
@state(SELECT)
|
||||
def select_point(self, ctx):
|
||||
point = ctx.user.points.filter(name=ctx.text.strip()).first()
|
||||
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 ctx.user.points.order_by('-created_at').first()
|
||||
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 ctx.user.points.filter(pk=point_id).first() 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
|
||||
point.status = Point.Status.RELEASING
|
||||
point.save(update_fields=['status'])
|
||||
tasks.release_point.send(point.pk, ctx.chat.pk)
|
||||
ctx.reset()
|
||||
ctx.reply(texts.RELEASE_STARTED, keyboard=texts.MAIN_KEYBOARD_WITH_POINTS)
|
||||
|
||||
@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 not point.servers.filter(address=credentials.address).exists():
|
||||
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):
|
||||
point.status = Point.Status.INSTALLING
|
||||
point.save(update_fields=['status'])
|
||||
tasks.reinstall_point.send(point.pk, ctx.chat.pk, address, password)
|
||||
ctx.reset()
|
||||
ctx.reply(texts.REINSTALL_STARTED, keyboard=texts.MAIN_KEYBOARD_WITH_POINTS)
|
||||
|
||||
@text(texts.NO, state=POINT_RELEASE)
|
||||
@text(texts.CANCEL, state=POINT_RELEASE)
|
||||
@text(texts.CANCEL, state=POINT_REINSTALL)
|
||||
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)
|
||||
@text(texts.POINT_PROXY, state=POINT)
|
||||
@text(texts.POINT_CLOUDFLARE, state=POINT)
|
||||
@text(texts.CLIENTS, state=POINT)
|
||||
def not_implemented(self, ctx):
|
||||
# TODO: настройки домена, прокси, cloudflare и клиенты через API панели 3x-ui
|
||||
ctx.reply(texts.NOT_IMPLEMENTED)
|
||||
Reference in new issue
Block a user