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:
parent
aa77771a3c
commit
4c793b494c
@ -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
206
assets/tgbot.js
Normal 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);
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -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 <компания>, /set <компания> | <текст>, /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>
|
||||||
|
|||||||
Reference in New Issue
Block a user