/* global React, ReactDOM */ (() => { const { useState, useEffect, useRef } = React; const DEMO_CHANNELS = { tg: [ { id: "city-detail", title: "Город в деталях", username: "city_in_detail" }, { id: "slow-home", title: "Дом без спешки", username: "slow_home" }, ], max: [ { id: "city-detail-max", title: "Город в деталях | MAX" }, { id: "slow-home-max", title: "Дом без спешки | MAX" }, ], }; const api = window.POSTILO_DEMO_MODE ? async (path, options = {}) => { const method = String(options.method || "GET").toUpperCase(); if (method !== "GET") throw new Error("В демо-режиме изменения не сохраняются."); if (path.startsWith("/api/tg_channels")) return DEMO_CHANNELS.tg; if (path.startsWith("/api/max_channels")) return DEMO_CHANNELS.max; if (path === "/api/mappings") return [{ id: "demo-map", mapping_kind: "tg_to_max", tg_channel_id: "city-detail", max_chat_id: "city-detail-max", source_title: "Город в деталях", target_title: "Город в деталях | MAX" }]; if (path.startsWith("/api/mappings/")) return { id: "demo-map", is_active: true, repost_delay_seconds: 60, mapping_signature_text: "" }; return {}; } : window.postiloRequest; const post = (path, body) => api(path, { method: "POST", body: JSON.stringify(body) }); const errorText = error => error?.message || "Не удалось выполнить действие."; function Notice({ error, children }) { return children ?
{children}
: null; } function Field({ label, labelAction, trailing, ...props }) { const id = React.useId(); return
{labelAction}
{trailing}
; } function Action({ busy, children, ...props }) { return ; } function DelaySelector({ value, onChange, disabled = false }) { const options = [{ value: 0, label: "Сразу" }, { value: 60, label: "1 минута" }, { value: 300, label: "5 минут" }, { value: 900, label: "15 минут" }]; return
Задержка перед переносом
{options.map(item => )}

Задержка даёт время исправить исходный пост до переноса. «Сразу» отправляет публикацию без ожидания.

{!options.some(item => item.value === Number(value)) && onChange(e.target.value)}/>}
; } function AccountError({ state }) { return state.error ? {state.error} : null; } function ConnectionForm({ platform, onConnected }) { const isMax = platform === "max"; const name = isMax ? "MAX" : "Telegram"; const [phone, setPhone] = useState(""); const [code, setCode] = useState(""); const [password, setPassword] = useState(""); const [step, setStep] = useState("phone"); const [busy, setBusy] = useState(false); const [error, setError] = useState(""); const [message, setMessage] = useState(""); const [resendAt, setResendAt] = useState(0); const [expiresAt, setExpiresAt] = useState(0); const [now, setNow] = useState(Date.now()); const mounted = useRef(true); useEffect(() => { mounted.current = true; const timer = setInterval(() => setNow(Date.now()), 1000); return () => { mounted.current = false; clearInterval(timer); }; }, []); const normalized = phone.replace(/[\s()\-]/g, ""); const expired = expiresAt > 0 && now >= expiresAt; const send = async (resend = false) => { if (busy) return; if (!/^\+[1-9]\d{9,14}$/.test(normalized)) { setError("Введите номер с кодом страны, например +79991234567."); return; } setBusy(true); setError(""); setMessage(""); try { const data = await post(isMax ? (resend ? "/api/auth/max_otp_resend" : "/api/auth/max_phone_otp") : "/api/auth/tg_mtproto_phone", { phone: normalized }); if (!["code_sent", "code_resent"].includes(data.status)) throw new Error("Сервис не подтвердил отправку кода."); if (!mounted.current) return; setPhone(data.phone || normalized); setStep("code"); setCode(""); setPassword(""); setNow(Date.now()); setResendAt(Date.now() + 60000); setExpiresAt(Date.now() + (data.ttl_seconds || 600) * 1000); setMessage(isMax ? "Запрос кода принят MAX. Проверьте SMS и приложение MAX." : "Код отправлен в Telegram. Откройте приложение и найдите сообщение о входе."); } catch (e) { if (mounted.current) { setError(errorText(e)); if ([404,410].includes(e.status)) setStep("phone"); } } finally { if (mounted.current) setBusy(false); } }; const confirm = async e => { e.preventDefault(); if (busy) return; if (step === "phone") { await send(); return; } if (expired) { setError("Срок действия кода истёк. Запросите новый код."); return; } setBusy(true); setError(""); setMessage(""); try { const data = await post(isMax ? "/api/auth/max_otp_confirm" : "/api/auth/tg_mtproto_confirm", { code, ...(step === "password" ? { password } : {}) }); if (!mounted.current) return; if (data.status === "password_required") { setStep("password"); setMessage("В Telegram включён облачный пароль. Введите его ниже."); return; } if (!["connected", "logged_in"].includes(data.status)) throw new Error("Подключение ещё не подтверждено. Проверьте статус."); await window.postiloAccountChanged(); if (mounted.current) { setCode(""); setPassword(""); setStep("done"); setMessage(name + " подключён к вашему аккаунту Postilo."); onConnected?.(); } } catch (e) { if (mounted.current) { setError(errorText(e)); if (e.status === 410) setStep("phone"); } } finally { if (mounted.current) setBusy(false); } }; return
{step !== "done" &&

{isMax ? "Подключите личный аккаунт MAX с правами администратора в нужных каналах. Создавать MAX-бота, оформлять ИП или организацию не нужно." : "Telegram-аккаунт нужен для чтения архива, больших видео и проверки участников. Для переноса новых публикаций подключите также своего Telegram-бота."}

} {step === "phone" && setPhone(e.target.value)} required disabled={busy}/>} {(step === "code" || step === "password") && <>

Номер: {phone}

{step === "code" ? setCode(e.target.value.replace(/\D/g, ""))} required disabled={busy}/> : setPassword(e.target.value)} required disabled={busy}/>} {expired && Код истёк. Запросите новый.} } {error}{message} {step !== "done" && {step === "phone" ? "Получить код" : "Подключить " + name}}

Код и пароль не сохраняются. После подтверждения зашифрованная сессия хранится на сервере Postilo для работы подключённых каналов.

; } function BotForm({ onConnected, compact = false }) { const state = window.usePostiloAccount(); const [token, setToken] = useState(""); const [busy, setBusy] = useState(false); const [error, setError] = useState(""); const [saved, setSaved] = useState(""); async function submit(e) { e.preventDefault(); if (busy) return; if (!/^\d{6,12}:[A-Za-z0-9_-]{30,}$/.test(token.trim())) { setError("Проверьте токен: скопируйте его целиком из BotFather."); return; } setBusy(true); setError(""); try { const data = await post("/api/tg_bot_token/verify", { token: token.trim() }); if (!data.ok) throw new Error("Telegram не подтвердил бота."); setToken(""); setSaved("Подключён бот @" + data.bot_username); await window.postiloAccountChanged(); onConnected?.(); } catch (e) { setError(errorText(e)); } finally { setBusy(false); } } return
{state.user?.tg_bot_connected && !compact && Ваш Telegram-бот: {state.user.tg_bot_username ? @{state.user.tg_bot_username} : "подключён"}. Откройте его в Telegram и нажмите «Запустить», чтобы он мог отправлять вам уведомления. Для другого бота можно заменить токен ниже.} {compact ? (

{state.user?.tg_bot_connected ? <>Вставьте новый токен, только если хотите заменить подключённого бота. Новый бот должен быть создан через @BotFather. : <>Создайте бота через @BotFather, скопируйте токен и вставьте его ниже.}

) : (
  1. Откройте @BotFather в Telegram и отправьте /newbot.
  2. Задайте название и username бота с окончанием bot. Скопируйте выданный токен.
  3. Вставьте токен ниже. Postilo проверит бота и привяжет его только к вашему аккаунту.
  4. Добавьте именно этого бота администратором нужных Telegram-каналов. Для канала-получателя разрешите публикацию сообщений; для переноса изменений — их редактирование.
)}
setToken(e.target.value)} required disabled={busy}/> {error}{saved} Проверить и подключить бота

Токен хранится зашифрованным. Не подключайте этого же бота одновременно к другому сервису.

; } function MappingForm({ onCreated, initialSource, onFirstDelivered }) { const state = window.usePostiloAccount(); const [kind, setKind] = useState(initialSource?.platform === "max" ? "max_to_tg" : "tg_to_max"); const [channels, setChannels] = useState({ tg: [], max: [] }); const [source, setSource] = useState(""); const [target, setTarget] = useState(""); const [busy, setBusy] = useState(false); const [loading, setLoading] = useState(false); const [error, setError] = useState(""); const [created, setCreated] = useState(null); const [delay, setDelay] = useState(60); const [sourcePlatform, targetPlatform] = kind.split("_to_"); const user = state.user; const needsTG = kind.includes("tg"); const needsMAX = kind.includes("max"); const ready = !!user && (!needsTG || user.tg_bot_connected) && (!needsMAX || user.max_connected); const refresh = async () => { setLoading(true); setError(""); try { const results = await Promise.allSettled(["tg", "max"].map(platform => api("/api/" + platform + "_channels?fast=1&refresh=1"))); const next = { tg: [], max: [] }; const errors = []; results.forEach((r, i) => { const platform = i === 0 ? "tg" : "max"; if (r.status === "fulfilled") next[platform] = Array.isArray(r.value) ? r.value : []; else errors.push((i === 0 ? "Telegram: " : "MAX: ") + errorText(r.reason)); }); setChannels(next); setSource(initialSource?.platform === sourcePlatform ? String(initialSource.id) : ""); setTarget(""); if (errors.length) setError(errors.join(" ")); } finally { setLoading(false); } }; useEffect(() => { if (user) refresh(); }, [user?.id, user?.tg_bot_connected, user?.max_connected]); const create = async e => { e.preventDefault(); if (busy || !ready || !source || !target) return; setBusy(true); setError(""); try { const result = await post("/api/mappings", { mapping_kind: kind, source_channel_id: source, target_channel_id: target, repost_delay_seconds: delay }); if (result.status !== "ok") throw new Error("Связка не сохранена."); const saved = await api("/api/mappings"); const match = saved.find(m => m.mapping_kind === kind && String(m.tg_channel_id) === source && String(m.max_chat_id) === target); if (!match) throw new Error("Не удалось подтвердить сохранение связки. Обновите список перед повтором."); const sourceItem = channels[sourcePlatform].find(c => String(c.id) === source); const targetItem = channels[targetPlatform].find(c => String(c.id) === target); setCreated({ id: Number(result.mapping_id || match.id), sourceTitle: sourceItem?.title || sourceItem?.name || sourceItem?.username || "исходном канале", targetTitle: targetItem?.title || targetItem?.name || targetItem?.username || "канале-получателе", }); onCreated?.(saved); } catch (e) { setError(errorText(e)); } finally { setBusy(false); } }; const select = (title, platform, value, setValue) => ; return

Связка — правило автоматического переноса новых постов из одного канала в другой. Старые публикации автоматически не переносятся.

{!ready && Для этого направления сначала подключите {needsTG && !user?.tg_bot_connected ? "своего Telegram-бота" : ""}{needsTG && !user?.tg_bot_connected && needsMAX && !user?.max_connected ? " и " : ""}{needsMAX && !user?.max_connected ? "MAX-аккаунт" : ""}.} {select("Откуда переносить", sourcePlatform, source, setSource)} {select("Куда публиковать", targetPlatform, target, setTarget)} {!created && } {!loading && (!channels[sourcePlatform].length || !channels[targetPlatform].length) && Канала нет в списке? В Telegram добавьте подключённого бота администратором. В MAX проверьте права подключённого аккаунта. Затем обновите список. Каналы и подключения других пользователей здесь не отображаются.} {error} {created ? : <>

Перенос включится для новых постов. Для архива подключите Telegram-аккаунт и запустите перенос старых публикаций отдельно.

Создать связку и включить перенос } ; } function FirstDeliveryCheck({ mapping, delay, onDelivered }) { const [result, setResult] = useState({ status: "waiting" }); const [busy, setBusy] = useState(false); const [error, setError] = useState(""); const startedAt = useRef(Date.now()); const reported = useRef(false); const check = async (silent = false) => { if (!mapping?.id || busy) return; if (!silent) setBusy(true); setError(""); try { const next = await api(`/api/mappings/${mapping.id}/first_delivery`); setResult(next); if (next.status === "delivered" && !reported.current) { reported.current = true; onDelivered?.(mapping.id); } } catch (e) { setError(errorText(e)); } finally { if (!silent) setBusy(false); } }; useEffect(() => { if (!mapping?.id) return undefined; startedAt.current = Date.now(); reported.current = false; const deadlineMs = Math.max(120000, Number(delay || mapping.repost_delay_seconds || 0) * 1000 + 300000); check(true); const timer = setInterval(() => { if (Date.now() - startedAt.current > deadlineMs) { clearInterval(timer); return; } setResult(current => { if (current.status === "delivered") clearInterval(timer); return current; }); check(true); }, 5000); return () => clearInterval(timer); }, [mapping?.id]); if (result.status === "delivered") return
Первая доставка подтверждена Postilo перенёс публикацию из «{result.source_title || mapping.sourceTitle}» в «{result.target_title || mapping.targetTitle}».
; return
{result.status === "error" || error ? "Первая доставка не прошла" : "Проверим первую реальную публикацию"} {result.status === "error" || error ? {error || result.error || "Проверьте права каналов и повторите публикацию."} : Опубликуйте короткий тестовый пост в «{mapping.sourceTitle}». Postilo проверит доставку в «{mapping.targetTitle}»{delay ? ` после задержки ${delay === 60 ? "1 минута" : Math.round(delay / 60) + " минут"}` : " без задержки"}.}
Открыть журнал
; } function SetupPanel({ compact = false }) { const state = window.usePostiloAccount(); const [section, setSection] = useState("bot"); const [mappings, setMappings] = useState(null); const [mappingError, setMappingError] = useState(""); const [deliveredMappingIds, setDeliveredMappingIds] = useState(new Set()); const user = state.user; const rememberDelivered = id => setDeliveredMappingIds(current => new Set([...current, Number(id)])); useEffect(() => { if (!user) return; let active = true; api("/api/mappings").then(async saved => { if (!active) return; setMappings(saved); const checks = await Promise.allSettled(saved.map(item => api(`/api/mappings/${item.id}/first_delivery`))); if (active) setDeliveredMappingIds(new Set(saved.filter((_, index) => checks[index].status === "fulfilled" && checks[index].value.status === "delivered").map(item => Number(item.id)))); }).catch(e => active && setMappingError(errorText(e))); return () => { active = false; }; }, [user?.id]); const hasFirstDelivery = deliveredMappingIds.size > 0; const steps = [ ["bot", "1. Telegram-бот", !!user?.tg_bot_connected], ["tg", "2. Telegram-аккаунт", !!user?.tg_account_connected], ["max", "3. MAX-аккаунт", !!user?.max_connected], ["mapping", "4. Первая доставка", hasFirstDelivery] ]; return
{!compact && <>

Настроим ваши каналы

Для Telegram → MAX подключите своего Telegram-бота и личный MAX-аккаунт, затем выберите каналы. MAX-бот, ИП или организация не нужны. Telegram-аккаунт дополнительно открывает перенос архива и больших видео.

} {mappingError} {state.loading ?

Загружаем ваши подключения…

: !user ? null : <>
s[0] === section)[1]}>

{steps.find(s => s[0] === section)[1]}

{section === "bot" && } {section === "tg" && (user.tg_account_connected ? Telegram подключён{user.tg_account_username ? ": @" + user.tg_account_username : ""}. Заменить аккаунт можно в настройках. : )} {section === "max" && (user.max_connected ? MAX подключён: {user.max_phone}. Заменить аккаунт можно в настройках. : )} {section === "mapping" && (!mappings?.length ? : hasFirstDelivery ? Первая реальная доставка подтверждена. Postilo готов переносить новые публикации автоматически. : <>Связка сохранена, но настройка завершится только после первой реальной доставки.)}
{section !== "mapping" && } {hasFirstDelivery ? "Перейти к каналам" : "Продолжить позже"}
{mappings?.length > 0 && Сохранённых связок: {mappings.length}. Результат переноса смотрите в журнале репостов. Наличие связки не заменяет проверку первой реальной публикации.} }
; } function MappingEditor({ mappingId, onSaved }) { const [value, setValue] = useState(null); const [busy, setBusy] = useState(false); const [error, setError] = useState(""); const [confirmDelete, setConfirmDelete] = useState(false); useEffect(() => { api("/api/mappings/" + mappingId).then(setValue).catch(e => setError(errorText(e))); }, [mappingId]); const save = async e => { e.preventDefault(); if (!value || busy) return; setBusy(true); setError(""); try { await api("/api/mappings/" + mappingId, { method: "PATCH", body: JSON.stringify({ is_active: value.is_active ? 1 : 0, repost_delay_seconds: Number(value.repost_delay_seconds), mapping_signature_text: value.mapping_signature_text || "", enable_repost_signature: value.mapping_signature_text?.trim() ? 1 : 0 }) }); onSaved?.(); } catch (e) { setError(errorText(e)); } finally { setBusy(false); } }; const remove = async () => { setBusy(true); setError(""); try { await api("/api/mappings/" + mappingId, { method: "DELETE" }); onSaved?.(); } catch (e) { setError(errorText(e)); } finally { setBusy(false); } }; return
{error} {!value ?

{error ? "Настройки не загружены." : "Загружаем настройки связки…"}

: <>

{value.source_title || "Исходный канал"} → {value.target_title || "Целевой канал"}

setValue({ ...value, repost_delay_seconds: next })}/>