71cddd6a91
Manage Telegram instances through Kubernetes with legacy adoption for forust and anna. Build and deploy the panel image alongside the runtime.
355 lines
12 KiB
Svelte
355 lines
12 KiB
Svelte
<script lang="ts">
|
|
import { ArrowLeft, Check, Eye, EyeOff, LoaderCircle, X } from 'lucide-svelte';
|
|
import { createEventDispatcher } from 'svelte';
|
|
|
|
import { api } from '../lib/api';
|
|
import type { AccountDraft, Instance } from '../lib/types';
|
|
|
|
export let open = false;
|
|
|
|
type Method = 'phone' | 'session';
|
|
type AuthPhase = 'code' | 'password';
|
|
|
|
const emptyDraft = (): AccountDraft => ({
|
|
instance_id: '',
|
|
display_name: '',
|
|
api_id: '',
|
|
api_hash: '',
|
|
phone: '',
|
|
session_string: '',
|
|
resources: {
|
|
storage: '1Gi',
|
|
cpu_limit: '300m',
|
|
memory_limit: '1536Mi',
|
|
},
|
|
});
|
|
|
|
let draft = emptyDraft();
|
|
let step = 1;
|
|
let method: Method = 'phone';
|
|
let authPhase: AuthPhase = 'code';
|
|
let flowId = '';
|
|
let code = '';
|
|
let password = '';
|
|
let error = '';
|
|
let busy = false;
|
|
let showHash = false;
|
|
let showPassword = false;
|
|
|
|
const dispatch = createEventDispatcher<{ close: void; created: Instance }>();
|
|
|
|
function reset() {
|
|
draft = emptyDraft();
|
|
step = 1;
|
|
method = 'phone';
|
|
authPhase = 'code';
|
|
flowId = '';
|
|
code = '';
|
|
password = '';
|
|
error = '';
|
|
busy = false;
|
|
showHash = false;
|
|
showPassword = false;
|
|
}
|
|
|
|
function close() {
|
|
reset();
|
|
dispatch('close');
|
|
}
|
|
|
|
function validSettings() {
|
|
return (
|
|
/^[a-z0-9](?:[a-z0-9-]{0,38}[a-z0-9])?$/.test(draft.instance_id) &&
|
|
draft.display_name.trim().length > 0 &&
|
|
Number(draft.api_id) > 0 &&
|
|
draft.api_hash.trim().length >= 16 &&
|
|
(method === 'phone'
|
|
? /^\+\d[\d\s()-]{6,}$/.test(draft.phone)
|
|
: draft.session_string.trim().length >= 32)
|
|
);
|
|
}
|
|
|
|
async function waitUntilReady(initial: Instance) {
|
|
let instance = initial;
|
|
for (let attempt = 0; attempt < 60 && instance.status === 'pending'; attempt += 1) {
|
|
await new Promise((resolve) => window.setTimeout(resolve, 2000));
|
|
instance = await api.instance(initial.instance_id);
|
|
if (instance.status === 'error') {
|
|
throw new Error(`Pod не запустился: ${instance.reason || 'неизвестная ошибка'}`);
|
|
}
|
|
}
|
|
return instance;
|
|
}
|
|
|
|
async function finishProvisioning(instance: Instance) {
|
|
step = 3;
|
|
const readyInstance = await waitUntilReady(instance);
|
|
dispatch('created', readyInstance);
|
|
close();
|
|
}
|
|
|
|
async function begin() {
|
|
error = '';
|
|
if (!validSettings()) {
|
|
error = 'Заполните обязательные поля и проверьте формат значений.';
|
|
return;
|
|
}
|
|
busy = true;
|
|
try {
|
|
if (method === 'session') {
|
|
step = 3;
|
|
const result = await api.stringSession(draft);
|
|
if (result.instance) {
|
|
await finishProvisioning(result.instance);
|
|
}
|
|
return;
|
|
}
|
|
const result = await api.startPhone(draft);
|
|
flowId = result.flow_id || '';
|
|
authPhase = 'code';
|
|
step = 2;
|
|
} catch (cause) {
|
|
error = cause instanceof Error ? cause.message : 'Не удалось начать авторизацию';
|
|
step = 1;
|
|
} finally {
|
|
busy = false;
|
|
}
|
|
}
|
|
|
|
async function submitCode() {
|
|
if (!flowId || !code.trim()) return;
|
|
error = '';
|
|
busy = true;
|
|
try {
|
|
const result = await api.submitCode(flowId, code);
|
|
if (result.status === 'password_required') {
|
|
authPhase = 'password';
|
|
} else if (result.instance) {
|
|
await finishProvisioning(result.instance);
|
|
}
|
|
} catch (cause) {
|
|
error = cause instanceof Error ? cause.message : 'Не удалось проверить код';
|
|
} finally {
|
|
busy = false;
|
|
}
|
|
}
|
|
|
|
async function submitPassword() {
|
|
if (!flowId || !password) return;
|
|
error = '';
|
|
busy = true;
|
|
try {
|
|
const result = await api.submitPassword(flowId, password);
|
|
if (result.instance) {
|
|
await finishProvisioning(result.instance);
|
|
}
|
|
} catch (cause) {
|
|
error = cause instanceof Error ? cause.message : 'Не удалось проверить пароль';
|
|
} finally {
|
|
busy = false;
|
|
}
|
|
}
|
|
</script>
|
|
|
|
{#if open}
|
|
<div class="drawer-backdrop" on:click={close} aria-hidden="true"></div>
|
|
<aside class="drawer add-drawer" aria-label="Добавить аккаунт">
|
|
<header class="drawer-header">
|
|
<div>
|
|
<h2>Добавить аккаунт</h2>
|
|
<p>Новый Kubernetes-инстанс userbot</p>
|
|
</div>
|
|
<button class="icon-button borderless" aria-label="Закрыть" on:click={close}>
|
|
<X size={19} />
|
|
</button>
|
|
</header>
|
|
|
|
<ol class="steps" aria-label="Шаги создания">
|
|
<li class:active={step === 1} class:complete={step > 1}><span>1</span>Настройки</li>
|
|
<li class:active={step === 2} class:complete={step > 2}><span>2</span>Авторизация</li>
|
|
<li class:active={step === 3}><span>3</span>Создание</li>
|
|
</ol>
|
|
|
|
{#if step === 1}
|
|
<form class="drawer-form" on:submit|preventDefault={begin}>
|
|
<fieldset>
|
|
<legend>Инстанс</legend>
|
|
<label>
|
|
Имя
|
|
<input
|
|
bind:value={draft.instance_id}
|
|
placeholder="personal"
|
|
maxlength="40"
|
|
autocomplete="off"
|
|
required
|
|
/>
|
|
<small>Только a-z, 0-9 и дефис</small>
|
|
</label>
|
|
<label>
|
|
Отображаемое имя
|
|
<input bind:value={draft.display_name} placeholder="Personal" maxlength="80" required />
|
|
</label>
|
|
</fieldset>
|
|
|
|
<fieldset>
|
|
<legend>Telegram API</legend>
|
|
<div class="two-columns">
|
|
<label>
|
|
API ID
|
|
<input bind:value={draft.api_id} inputmode="numeric" placeholder="12345678" required />
|
|
</label>
|
|
<label>
|
|
API Hash
|
|
<span class="input-with-action">
|
|
<input
|
|
bind:value={draft.api_hash}
|
|
type={showHash ? 'text' : 'password'}
|
|
placeholder="0123456789abcdef"
|
|
required
|
|
/>
|
|
<button
|
|
type="button"
|
|
aria-label={showHash ? 'Скрыть API Hash' : 'Показать API Hash'}
|
|
on:click={() => (showHash = !showHash)}
|
|
>
|
|
{#if showHash}<EyeOff size={15} />{:else}<Eye size={15} />{/if}
|
|
</button>
|
|
</span>
|
|
</label>
|
|
</div>
|
|
</fieldset>
|
|
|
|
<fieldset>
|
|
<legend>Авторизация</legend>
|
|
<div class="segmented">
|
|
<button type="button" class:active={method === 'phone'} on:click={() => (method = 'phone')}>
|
|
По номеру
|
|
</button>
|
|
<button type="button" class:active={method === 'session'} on:click={() => (method = 'session')}>
|
|
StringSession
|
|
</button>
|
|
</div>
|
|
{#if method === 'phone'}
|
|
<label>
|
|
Номер телефона
|
|
<input bind:value={draft.phone} type="tel" placeholder="+421 900 123 456" required />
|
|
<small>Код придёт в Telegram. При включённой 2FA потребуется пароль.</small>
|
|
</label>
|
|
{:else}
|
|
<label>
|
|
StringSession
|
|
<textarea
|
|
bind:value={draft.session_string}
|
|
rows="3"
|
|
placeholder="Вставьте Pyrogram/Pyrofork StringSession"
|
|
required
|
|
></textarea>
|
|
</label>
|
|
{/if}
|
|
</fieldset>
|
|
|
|
<fieldset>
|
|
<legend>Ресурсы</legend>
|
|
<div class="three-columns">
|
|
<label>
|
|
Хранилище
|
|
<select bind:value={draft.resources.storage}>
|
|
<option value="1Gi">1 GiB</option>
|
|
<option value="2Gi">2 GiB</option>
|
|
<option value="5Gi">5 GiB</option>
|
|
</select>
|
|
</label>
|
|
<label>
|
|
CPU
|
|
<select bind:value={draft.resources.cpu_limit}>
|
|
<option value="200m">200m</option>
|
|
<option value="300m">300m</option>
|
|
<option value="500m">500m</option>
|
|
</select>
|
|
</label>
|
|
<label>
|
|
RAM
|
|
<select bind:value={draft.resources.memory_limit}>
|
|
<option value="1024Mi">1 GiB</option>
|
|
<option value="1536Mi">1.5 GiB</option>
|
|
<option value="2048Mi">2 GiB</option>
|
|
</select>
|
|
</label>
|
|
</div>
|
|
</fieldset>
|
|
|
|
{#if error}<p class="form-error" role="alert">{error}</p>{/if}
|
|
|
|
<footer class="form-actions">
|
|
<button type="button" on:click={close}>Отмена</button>
|
|
<button class="primary" type="submit" disabled={busy}>
|
|
{#if busy}<LoaderCircle class="spin" size={16} />{/if}
|
|
{method === 'phone' ? 'Запросить код' : 'Создать инстанс'}
|
|
</button>
|
|
</footer>
|
|
</form>
|
|
{:else if step === 2}
|
|
<div class="auth-step">
|
|
<button class="back-link" on:click={() => (step = 1)}>
|
|
<ArrowLeft size={15} />Назад к настройкам
|
|
</button>
|
|
{#if authPhase === 'code'}
|
|
<h3>Введите код</h3>
|
|
<p>Код отправлен в Telegram на {draft.phone}.</p>
|
|
<form on:submit|preventDefault={submitCode}>
|
|
<label>
|
|
Код Telegram
|
|
<input
|
|
class="code-input"
|
|
bind:value={code}
|
|
inputmode="numeric"
|
|
autocomplete="one-time-code"
|
|
placeholder="12345"
|
|
/>
|
|
</label>
|
|
{#if error}<p class="form-error" role="alert">{error}</p>{/if}
|
|
<button class="primary full" type="submit" disabled={busy || !code.trim()}>
|
|
{#if busy}<LoaderCircle class="spin" size={16} />{/if}
|
|
Авторизовать
|
|
</button>
|
|
</form>
|
|
{:else}
|
|
<h3>Пароль 2FA</h3>
|
|
<p>Для этого аккаунта включена двухэтапная аутентификация.</p>
|
|
<form on:submit|preventDefault={submitPassword}>
|
|
<label>
|
|
Пароль
|
|
<span class="input-with-action">
|
|
<input
|
|
bind:value={password}
|
|
type={showPassword ? 'text' : 'password'}
|
|
autocomplete="current-password"
|
|
/>
|
|
<button
|
|
type="button"
|
|
aria-label={showPassword ? 'Скрыть пароль' : 'Показать пароль'}
|
|
on:click={() => (showPassword = !showPassword)}
|
|
>
|
|
{#if showPassword}<EyeOff size={15} />{:else}<Eye size={15} />{/if}
|
|
</button>
|
|
</span>
|
|
</label>
|
|
{#if error}<p class="form-error" role="alert">{error}</p>{/if}
|
|
<button class="primary full" type="submit" disabled={busy || !password}>
|
|
{#if busy}<LoaderCircle class="spin" size={16} />{/if}
|
|
Продолжить
|
|
</button>
|
|
</form>
|
|
{/if}
|
|
</div>
|
|
{:else}
|
|
<div class="creating-state">
|
|
<LoaderCircle class="spin" size={28} />
|
|
<h3>Создаём инстанс</h3>
|
|
<p>Secret, PVC и Deployment созданы. Ждём Ready в namespace userbot.</p>
|
|
{#if error}<p class="form-error" role="alert">{error}</p>{/if}
|
|
</div>
|
|
{/if}
|
|
</aside>
|
|
{/if}
|