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;
|
||||
window.toast("Настройки Telegram сохранены ✓", "ok");
|
||||
window.TelegramBot.restart();
|
||||
});
|
||||
$("#tg-test").addEventListener("click", async () => {
|
||||
const cfg = {
|
||||
@ -304,6 +305,8 @@ function init(){
|
||||
render();
|
||||
setInterval(updateClock, 1000);
|
||||
window.Alerts.start();
|
||||
window.refreshUI = render;
|
||||
window.TelegramBot.start();
|
||||
}
|
||||
|
||||
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>
|
||||
</div>
|
||||
</form>
|
||||
<p class="hint">Если поля пустые — уведомления только в браузере.</p>
|
||||
<p class="hint">Если поля пустые — уведомления только в браузере.<br>
|
||||
Пока эта вкладка открыта, бот отвечает на команды: /help, /list, /status <компания>, /set <компания> | <текст>, /overdue.</p>
|
||||
</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/store.js"></script>
|
||||
<script src="./assets/alerts.js"></script>
|
||||
<script src="./assets/tgbot.js"></script>
|
||||
<script src="./assets/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user