feat: Telegram bot long-polling — commands in chat

Listens via getUpdates while tab is open.
Commands: /help, /list, /status <company>, /set <company> | <text>, /overdue.
Authorization gates by configured chatManager/chatRop.
Auto-starts on init, restarts on settings save.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
askhat 2026-05-19 12:36:28 +05:00
parent aa77771a3c
commit 4c793b494c
3 changed files with 212 additions and 1 deletions

View File

@ -261,6 +261,7 @@ function bindEvents(){
}); });
$("#tg-modal").hidden = true; $("#tg-modal").hidden = true;
window.toast("Настройки Telegram сохранены ✓", "ok"); window.toast("Настройки Telegram сохранены ✓", "ok");
window.TelegramBot.restart();
}); });
$("#tg-test").addEventListener("click", async () => { $("#tg-test").addEventListener("click", async () => {
const cfg = { const cfg = {
@ -304,6 +305,8 @@ function init(){
render(); render();
setInterval(updateClock, 1000); setInterval(updateClock, 1000);
window.Alerts.start(); window.Alerts.start();
window.refreshUI = render;
window.TelegramBot.start();
} }
document.addEventListener("DOMContentLoaded", init); document.addEventListener("DOMContentLoaded", init);

206
assets/tgbot.js Normal file
View File

@ -0,0 +1,206 @@
// Long-polling Telegram бот. Слушает входящие команды пока вкладка открыта.
const OFFSET_KEY = "ddst.tg.offset.v1";
const POLL_TIMEOUT = 25; // секунд long-poll
let polling = false;
let pollAbort = null;
function loadOffset(){ return parseInt(localStorage.getItem(OFFSET_KEY) || "0", 10); }
function saveOffset(n){ localStorage.setItem(OFFSET_KEY, String(n)); }
async function apiCall(method, params, opts={}){
const cfg = window.TelegramCfg.load();
if(!cfg.token) throw new Error("no token");
const r = await fetch(`https://api.telegram.org/bot${cfg.token}/${method}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(params),
signal: opts.signal
});
return r.json();
}
function authorizedChats(){
const cfg = window.TelegramCfg.load();
const list = [];
if(cfg.chatManager) list.push(String(cfg.chatManager));
if(cfg.chatRop) list.push(String(cfg.chatRop));
return list;
}
function isAuthorized(chatId, username){
const allowed = authorizedChats();
if(allowed.length === 0) return true; // если не задано — пускаем всех (полезно для теста)
const cid = String(chatId);
const uname = username ? "@"+username : "";
return allowed.includes(cid) || (uname && allowed.includes(uname));
}
function isRop(chatId){
const cfg = window.TelegramCfg.load();
return cfg.chatRop && String(chatId) === String(cfg.chatRop);
}
function findDealByCompany(name){
const needle = name.trim().toLowerCase();
return window.Store.list().find(d => d.company.toLowerCase().includes(needle));
}
async function handleCommand(msg){
const chatId = msg.chat.id;
const text = (msg.text || "").trim();
if(!text.startsWith("/")) return;
if(!isAuthorized(chatId, msg.from && msg.from.username)){
await apiCall("sendMessage", { chat_id: chatId, text: "⛔ Этот чат не настроен в системе. Добавьте ваш ID в настройках Telegram." });
return;
}
const [rawCmd, ...rest] = text.split(/\s+/);
const cmd = rawCmd.split("@")[0].toLowerCase();
const args = rest.join(" ");
if(cmd === "/start" || cmd === "/help"){
await apiCall("sendMessage", { chat_id: chatId, text:
`📊 Daily Deal Status Tracker — бот
/list — мои активные сделки
/status <компания> — текущий статус сделки
/set <компания> | <текст> — обновить статус (мин ${window.MIN_STATUS_LEN} симв)
/overdue — просрочки (для РОПа)
/help — эта справка
Пример: /set Вектор | ЛПР согласовал бюджет, ждём ответ юристов` });
return;
}
if(cmd === "/list"){
const deals = window.Store.list()
.filter(d => window.Store.inControl(d))
.map(d => ({ d, days: window.Store.daysWithoutUpdate(d), color: window.Store.colorOf(d) }))
.sort((a,b) => b.days - a.days);
if(deals.length === 0){
await apiCall("sendMessage", { chat_id: chatId, text: "Нет сделок на контроле." });
return;
}
const lines = deals.map(({d, days, color}) => `${color === "red" ? "🔴" : "🟢"} ${d.company} — ${d.manager} · ${days} дн.`);
await apiCall("sendMessage", { chat_id: chatId, text: "Сделки на контроле:\n" + lines.join("\n") });
return;
}
if(cmd === "/status"){
if(!args){
await apiCall("sendMessage", { chat_id: chatId, text: "Использование: /status <компания>" });
return;
}
const d = findDealByCompany(args);
if(!d){
await apiCall("sendMessage", { chat_id: chatId, text: `Сделка с "${args}" не найдена.` });
return;
}
const days = window.Store.daysWithoutUpdate(d);
const color = window.Store.colorOf(d);
const last = d.lastUpdate ? new Date(d.lastUpdate).toLocaleString("ru-RU") : "—";
await apiCall("sendMessage", { chat_id: chatId, text:
`${color === "red" ? "🔴" : "🟢"} <b>${d.company}</b>
Менеджер: ${d.manager}
Этап: ${d.stage}
Дней без обновлений: ${days}
Последнее обновление: ${last}
Статус: ${d.currentStatus || "—"}`,
parse_mode: "HTML" });
return;
}
if(cmd === "/set"){
// формат: /set <компания> | <текст>
const sep = args.indexOf("|");
if(sep === -1){
await apiCall("sendMessage", { chat_id: chatId, text: "Использование: /set <компания> | <текст>" });
return;
}
const company = args.slice(0, sep).trim();
const newStatus = args.slice(sep+1).trim();
if(!company || !newStatus){
await apiCall("sendMessage", { chat_id: chatId, text: "Укажите и компанию, и текст." });
return;
}
const d = findDealByCompany(company);
if(!d){
await apiCall("sendMessage", { chat_id: chatId, text: `Сделка "${company}" не найдена.` });
return;
}
try {
window.Store.updateStatus(d.id, newStatus);
if(window.refreshUI) window.refreshUI();
await apiCall("sendMessage", { chat_id: chatId, text: `✅ Статус '${d.company}' обновлён.` });
} catch(err){
await apiCall("sendMessage", { chat_id: chatId, text: `❌ ${err.message}` });
}
return;
}
if(cmd === "/overdue"){
if(!isRop(chatId)){
await apiCall("sendMessage", { chat_id: chatId, text: "Команда только для РОПа. Настройте 'Chat ID РОПа' с этим chat_id." });
return;
}
const text = window.Alerts.buildRopSummary();
await apiCall("sendMessage", { chat_id: chatId, text });
return;
}
await apiCall("sendMessage", { chat_id: chatId, text: "Неизвестная команда. /help — список." });
}
async function pollLoop(){
while(polling){
try {
pollAbort = new AbortController();
const offset = loadOffset();
const r = await apiCall("getUpdates", { offset, timeout: POLL_TIMEOUT, allowed_updates: ["message"] }, { signal: pollAbort.signal });
if(r && r.ok && Array.isArray(r.result)){
for(const upd of r.result){
saveOffset(upd.update_id + 1);
if(upd.message){
try { await handleCommand(upd.message); }
catch(e){ console.error("cmd handler fail", e); }
}
}
} else if(r && !r.ok){
console.error("getUpdates error", r);
await sleep(5000);
}
} catch(e){
if(e.name === "AbortError") break;
console.error("poll fail", e);
await sleep(5000);
}
}
}
function sleep(ms){ return new Promise(r => setTimeout(r, ms)); }
window.TelegramBot = {
start(){
const cfg = window.TelegramCfg.load();
if(!cfg.token){
if(polling) this.stop();
return false;
}
if(polling) return true;
polling = true;
pollLoop();
return true;
},
stop(){
polling = false;
if(pollAbort){ try { pollAbort.abort(); } catch(e){} }
},
isRunning(){ return polling; },
restart(){
this.stop();
setTimeout(() => this.start(), 200);
}
};

View File

@ -186,7 +186,8 @@ td.status-cell textarea:focus{background:#fff;outline:2px solid var(--primary)}
<button type="submit" class="primary">Сохранить</button> <button type="submit" class="primary">Сохранить</button>
</div> </div>
</form> </form>
<p class="hint">Если поля пустые — уведомления только в браузере.</p> <p class="hint">Если поля пустые — уведомления только в браузере.<br>
Пока эта вкладка открыта, бот отвечает на команды: /help, /list, /status &lt;компания&gt;, /set &lt;компания&gt; | &lt;текст&gt;, /overdue.</p>
</div> </div>
</div> </div>
@ -195,6 +196,7 @@ td.status-cell textarea:focus{background:#fff;outline:2px solid var(--primary)}
<script src="./assets/holidays.js"></script> <script src="./assets/holidays.js"></script>
<script src="./assets/store.js"></script> <script src="./assets/store.js"></script>
<script src="./assets/alerts.js"></script> <script src="./assets/alerts.js"></script>
<script src="./assets/tgbot.js"></script>
<script src="./assets/app.js"></script> <script src="./assets/app.js"></script>
</body> </body>
</html> </html>