Files
MikrotikManager/components/alerts-telegram-form.tsx
T

82 lines
9.6 KiB
TypeScript

"use client"
import {useEffect,useRef,useState} from "react"
import {EyeIcon,EyeOffIcon} from "lucide-react"
import type {PutTelegramBody} from "@mmapp/contracts/alerts"
import {can} from "@/lib/auth"
import {ApiClientError} from "@/shared/api/http-client"
import {saveAlertsTelegram,testAlertsTelegram} from "@/shared/api/alerts"
import {OpsPanel} from "@/components/ops-panel"
import {Button} from "@/components/ui/button"
import {Input} from "@/components/ui/input"
export type TelegramDraft={token:string;chatId:string;messageThreadId:string;connected:boolean}
const pending=new Set<string>()
const storageKey=(source:string)=>"mm:alerts-telegram-pending:"+source
function unresolved(source:string){try{return pending.has(source)||sessionStorage.getItem(storageKey(source))==="true"}catch{return pending.has(source)}}
function checkpoint(source:string,value:boolean){if(value)pending.add(source);else pending.delete(source);try{if(value)sessionStorage.setItem(storageKey(source),"true");else sessionStorage.removeItem(storageKey(source))}catch{/* The memory guard remains active during this browser lifetime. */}}
function thread(value:string){if(!value.trim())return null;if(!/^\d+$/.test(value.trim()))throw Error("ID темы: укажите положительное целое число или оставьте поле пустым");const n=Number(value);if(!Number.isSafeInteger(n)||n<1||n>2147483647)throw Error("ID темы должен быть от 1 до 2147483647");return n}
export function AlertsTelegramForm({initial,tokenConfigured,base,live,available,onBegin,onEnd,onSaved}:{initial:TelegramDraft;tokenConfigured:boolean;base:string;live:boolean;available:boolean;onBegin:()=>boolean;onEnd:()=>void;onSaved:(value:TelegramDraft,configured:boolean)=>void}){
const source=JSON.stringify([live,base])
const [draft,setDraft]=useState<Partial<Pick<TelegramDraft,"token"|"chatId"|"messageThreadId">>>({})
const [showToken,setShowToken]=useState(false),[busy,setBusy]=useState<"save"|"test"|null>(null),[error,setError]=useState<string|null>(null),[outcome,setOutcome]=useState<string|null>(null),[unknown,setUnknown]=useState(false),[ready,setReady]=useState(false)
const active=useRef(true),lock=useRef(false),controller=useRef<AbortController|null>(null)
useEffect(()=>{active.current=true;queueMicrotask(()=>{if(active.current){setUnknown(unresolved(source));setReady(true)}});return()=>{active.current=false;controller.current?.abort()}},[source])
const value={...initial,...draft},allowed=can("mm:alerts:write"),enabled=ready&&live&&available&&allowed
const dirty=Object.keys(draft).some(key=>value[key as keyof typeof draft]!==initial[key as keyof typeof draft])
function current(){return active.current&&can("mm:alerts:write")}
function change(key:keyof typeof draft,value:string){setDraft(previous=>({...previous,[key]:value}));setError(null);setOutcome(null)}
async function execute(kind:"save"|"test"){
if(!enabled||lock.current||unknown||!current())return
const payload:PutTelegramBody={}
try{
const messageThreadId=thread(value.messageThreadId)
if(kind==="save"){
if(draft.chatId!==undefined&&value.chatId!==initial.chatId)payload.chatId=value.chatId
if(draft.messageThreadId!==undefined&&value.messageThreadId!==initial.messageThreadId)payload.messageThreadId=messageThreadId
if(value.token.trim())payload.token=value.token.trim()
if(!Object.keys(payload).length){setError("Нет изменённых параметров Telegram");return}
}else{
if(!value.chatId.trim())throw Error("Укажите Chat ID для тестового сообщения")
if(!value.token.trim()&&!tokenConfigured)throw Error("Укажите токен или сохраните его в БД")
if(draft.messageThreadId!==undefined&&!messageThreadId&&initial.messageThreadId)throw Error("Сначала сохраните очистку ID темы: тестовый API использует сохранённую тему, если ID не указан")
}
}catch(e){setError(e instanceof Error?e.message:"Проверьте параметры Telegram");return}
if(!onBegin())return
lock.current=true;controller.current=new AbortController();setBusy(kind);setError(null);setOutcome(null)
checkpoint(source,true);setUnknown(true)
try{
if(kind==="save"){
const result=await saveAlertsTelegram(base,payload,{signal:controller.current.signal});if(!current())return
const saved=result.telegram
onSaved({token:"",chatId:saved.chatId,messageThreadId:saved.messageThreadId===null?"":String(saved.messageThreadId),connected:saved.connected},saved.tokenConfigured)
setDraft({});setOutcome("Параметры Telegram сохранены в БД")
}else{
const messageThreadId=thread(value.messageThreadId)
await testAlertsTelegram(base,{chatId:value.chatId.trim(),...(value.token.trim()?{token:value.token.trim()}:{}),...(messageThreadId!==null?{messageThreadId}:{})},{signal:controller.current.signal});if(!current())return
setOutcome("Telegram API подтвердил приём тестового сообщения. Проверьте его в чате.")
}
checkpoint(source,false);setUnknown(false)
}catch(e){
const rejected=e instanceof ApiClientError&&[400,401,403,409,422].includes(e.status)
if(rejected&&current())checkpoint(source,false)
if(current()){setUnknown(!rejected);setError(e instanceof Error?e.message:"Результат Telegram не получен")}
}finally{lock.current=false;controller.current=null;if(active.current){setBusy(null);onEnd()}}
}
return <section aria-label="Параметры Telegram" className="min-w-0"><OpsPanel title="Telegram" description={initial.connected?"Параметры заполнены. Доставка проверяется отдельно.":"Параметры не заполнены."} contentClassName="flex min-w-0 flex-col gap-3 px-5 py-4">
<div className="space-y-1"><label htmlFor="alerts-bot-token" className="text-xs font-medium">Bot Token</label><div className="flex gap-2"><Input id="alerts-bot-token" type={showToken?"text":"password"} value={value.token} disabled={!enabled||!!busy} onChange={e=>change("token",e.target.value)} autoComplete="new-password" placeholder={tokenConfigured?"Токен сохранён; введите новый для замены":"Токен Telegram бота"} className="min-w-0 font-mono text-xs"/><Button type="button" variant="outline" size="icon" aria-label={showToken?"Скрыть токен":"Показать токен"} onClick={()=>setShowToken(previous=>!previous)}>{showToken?<EyeOffIcon aria-hidden/>:<EyeIcon aria-hidden/>}</Button></div></div>
<div className="space-y-1"><label htmlFor="alerts-chat-id" className="text-xs font-medium">Chat ID по умолчанию</label><Input id="alerts-chat-id" value={value.chatId} disabled={!enabled||!!busy} onChange={e=>change("chatId",e.target.value)} className="min-w-0 font-mono text-xs"/><p className="text-xs text-muted-foreground">Для групп используйте отрицательный ID. Правило может задать свой чат.</p></div>
<div className="space-y-1"><label htmlFor="alerts-thread-id" className="text-xs font-medium">ID темы (message_thread_id)</label><Input id="alerts-thread-id" value={value.messageThreadId} disabled={!enabled||!!busy} inputMode="numeric" onChange={e=>change("messageThreadId",e.target.value)} className="min-w-0 font-mono text-xs"/><p className="text-xs text-muted-foreground">Пустое поле при сохранении очищает тему. Только для форум-супергрупп.</p></div>
{dirty?<p role="status" className="text-xs text-muted-foreground">Есть несохранённые изменения. Обновление страницы данных сохранит черновик.</p>:null}
{!live?<p className="text-xs text-muted-foreground">Демо: сохранение и отправка недоступны.</p>:!allowed?<p role="status" className="text-xs text-muted-foreground">Нет права изменять оповещения.</p>:null}
{error?<p role="alert" className="text-xs text-destructive break-words">{error}</p>:null}
{outcome?<p role="status" className="text-xs break-words">{outcome}</p>:null}
{busy?<p role="status" className="text-xs text-muted-foreground">Ожидаем подтверждение сервера…</p>:null}
{unknown&&!busy?<div className="space-y-2"><p role="status" className="text-xs break-words">Результат запроса неизвестен. Он мог выполниться. Перед повтором проверьте параметры в БД или сообщение в Telegram; обновление данных не подтверждает отправку.</p><Button type="button" size="sm" variant="outline" className="h-auto whitespace-normal" disabled={!!busy||!enabled} onClick={()=>{checkpoint(source,false);setUnknown(false)}}>Я проверил результат — разрешить новый запрос</Button></div>:null}
<Button type="button" size="sm" disabled={!enabled||!!busy||unknown||!dirty} onClick={()=>void execute("save")}>{busy==="save"?"Сохранение…":"Сохранить в БД"}</Button>
<Button type="button" size="sm" variant="outline" disabled={!enabled||!!busy||unknown} onClick={()=>void execute("test")}>{busy==="test"?"Отправка…":"Отправить тестовое сообщение"}</Button>
<Button type="button" size="sm" variant="ghost" disabled={!dirty||!!busy} onClick={()=>{setDraft({});setError(null);setOutcome(null)}}>Сбросить черновик</Button>
</OpsPanel></section>
}