效果:
节点离线/上线:

到期提醒/续费通知:

面板登录/流量预警:

资源告警:

流量统计:

在 Komari 后台通知渠道中选择 Javascript,把整个脚本粘贴到 JavaScript 代码 并保存,在保存前配置好你自己的TG bot token和chat id与站点域名。
源码:
var KOMARI_TG_CONFIG = {
endpoint: 'https://api.telegram.org/bot',
bot_token: 'YOUR_BOT_TOKEN',
chat_id: 'YOUR_CHAT_ID',
message_thread_id: '' // 可选:Telegram 群组话题 ID;普通消息留空
};
// 你的 Komari 站点地址 比如https://monitor.example.com
var KOMARI_SITE_URL = '';
// Internal cache, populated only from notification events; no manual region configuration.
var learnedNodeRegions = Object.create(null);
var publicRegionState = {site: '', regions: Object.create(null), expiresAt: 0, retryAt: 0, pending: null};
var deliveredPages = Object.create(null);
function text(value) { return value === null || value === undefined ? '' : String(value); }
function esc(value) { return text(value).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); }
function strong(value) { return '<b>' + esc(value) + '</b>'; }
function pad(value) { return ('0' + value).slice(-2); }
function dateValue(value) {
var result = new Date(text(value).replace(/(\.\d{3})\d+/, '$1'));
return isNaN(result.getTime()) ? new Date() : result;
}
function beijing(value) { return new Date(dateValue(value).getTime() + 8 * 3600000); }
function dayString(date) { return date.getUTCFullYear() + '.' + pad(date.getUTCMonth() + 1) + '.' + pad(date.getUTCDate()); }
function clockString(value) {
var d = beijing(value);
return dayString(d) + ' - ' + pad(d.getUTCHours()) + ':' + pad(d.getUTCMinutes()) + ':' + pad(d.getUTCSeconds());
}
function countryFlag(region) {
var raw = text(region).trim();
if (/^[a-z]{2}$/i.test(raw)) {
raw = raw.toUpperCase();
return String.fromCodePoint(127397 + raw.charCodeAt(0), 127397 + raw.charCodeAt(1));
}
var points = Array.from(raw);
return points.length === 2 && points.every(function (p) {
var n = p.codePointAt(0); return n >= 127462 && n <= 127487;
}) ? raw : '';
}
function clientName(client) { return text(client.name || client.uuid || '未命名节点'); }
function learnClients(clients) {
clients.forEach(function (client) {
var name = clientName(client);
var flag = countryFlag(client.region);
if (flag) learnedNodeRegions[name] = flag;
});
}
function nodeName(clientOrName) {
var client = typeof clientOrName === 'string' ? { name: clientOrName } : clientOrName;
var name = clientName(client);
var stored = Object.prototype.hasOwnProperty.call(learnedNodeRegions, name) ? learnedNodeRegions[name] : '';
var publicFlag = publicRegionState.expiresAt > Date.now() ? publicRegionState.regions[name] : '';
var flag = countryFlag(client.region) || countryFlag(stored) || countryFlag(publicFlag);
return flag ? flag + ' ' + name : name;
}
function regionLookupSite() {
var site = text(KOMARI_SITE_URL).trim().replace(/\/+$/, '');
// A site URL only: no credentials, query parameters, or fragments.
return /^https?:\/\/[^\s\/?#@]+(?:\/[^\s?#]*)?$/i.test(site) ? site : '';
}
function eventNodeNames(event) {
var names = (Array.isArray(event.clients) ? event.clients : []).map(clientName);
var kind = text(event.event);
var pattern = kind === 'Expire' ? /^\s*[•·-]?\s*(.+?)\s+\(-?\d+d\)\s*$/ :
kind === 'Renew' ? /^\s*[•·-]?\s*(.+?)\s+until\s+\d{4}-\d{2}-\d{2}\s*$/ :
/^(DReport|WReport|MReport)$/.test(kind) ? /^(.*?)(?:昨日流量|上周流量|上个月流量)[::]/ : null;
if (pattern) text(event.message).split(/\r?\n/).forEach(function (line) {
var match = line.match(pattern);
if (match) names.push(match[1]);
});
return names.filter(function (name, index) { return names.indexOf(name) === index; });
}
function prepareEventRegions(event) {
learnClients(Array.isArray(event.clients) ? event.clients : []);
var site = regionLookupSite();
if (site !== publicRegionState.site) {
publicRegionState = {site: site, regions: Object.create(null), expiresAt: 0, retryAt: 0, pending: null};
}
var state = publicRegionState;
if (!site) return Promise.resolve();
var now = Date.now();
var missing = eventNodeNames(event).some(function (name) {
return !countryFlag(learnedNodeRegions[name]) && !(state.expiresAt > now && countryFlag(state.regions[name]));
});
if (!missing) return Promise.resolve();
if (state.pending) return state.pending;
if (now < state.retryAt) return Promise.resolve();
state.retryAt = now + 60000;
state.pending = new Promise(function (resolve) {
var finished = false;
var controller = typeof AbortController === 'function' ? new AbortController() : null;
var timer = setTimeout(function () {
finish(null);
if (controller) controller.abort();
}, 2000);
function finish(nodes) {
if (finished) return;
finished = true;
clearTimeout(timer);
var regions = Object.create(null);
if (nodes) nodes.forEach(function (node) {
if (!node || typeof node.name !== 'string') return;
var flag = countryFlag(node.region);
if (!flag) return;
if (Object.prototype.hasOwnProperty.call(regions, node.name) && regions[node.name] !== flag) {
regions[node.name] = ''; // Conflicting duplicate names cannot be resolved reliably.
} else regions[node.name] = flag;
});
state.regions = regions;
state.expiresAt = nodes ? Date.now() + 300000 : 0;
if (!nodes) state.retryAt = Date.now() + 300000;
resolve();
}
var options = {
method: 'POST', credentials: 'omit',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({jsonrpc: '2.0', id: 'notification-regions', method: 'public:getNodesInformation', params: {}})
};
if (controller) options.signal = controller.signal;
Promise.resolve().then(function () {
return fetch(site + '/api/rpc2', options);
}).then(function (response) {
if (!response.ok) throw new Error('Public node lookup unavailable');
return response.json();
}).then(function (body) {
if (!body || body.error || !Array.isArray(body.result)) throw new Error('Invalid public node response');
finish(body.result);
}).catch(function () { finish(null); });
}).then(function () { state.pending = null; });
return state.pending;
}
function modeLabel(mode) {
return ({up: '仅出站', down: '仅入站', sum: '上下行合计', max: '上下行取较大值', min: '上下行取较小值'})[text(mode).toLowerCase()] || text(mode);
}
function clientBlock(client, detail) { return strong(nodeName(client)) + (detail ? '\n' + esc(detail) : ''); }
function plainBlocks(value) {
var chars = Array.from(text(value));
var result = [];
for (var i = 0; i < chars.length; i += 450) result.push(esc(chars.slice(i, i + 450).join('')));
return result;
}
function byteValue(number, unit) {
var power = ({B: 0, KB: 1, KIB: 1, MB: 2, MIB: 2, GB: 3, GIB: 3, TB: 4, TIB: 4, PB: 5, PIB: 5, EB: 6, EIB: 6})[unit.toUpperCase()];
return power === undefined ? NaN : Number(number) * Math.pow(1024, power);
}
function volume(bytes, scale) { return (bytes / scale.divisor).toFixed(2); }
function scaleFor(bytes) {
var units = ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB'];
var power = bytes > 0 ? Math.min(6, Math.floor(Math.log(bytes) / Math.log(1024))) : 0;
return { divisor: Math.pow(1024, power), unit: units[Math.max(0, power)] };
}
function percentage(value) { return value.toFixed(1).replace(/\.0$/, ''); }
function billing(client) {
var cycle = ({30: '月付', 92: '季付', 365: '年付'})[client.billing_cycle];
if (!cycle && Number(client.billing_cycle) > 0) cycle = client.billing_cycle + ' 天周期';
if (Number(client.price) === -1) return '免费' + (cycle ? ' / ' + cycle : '');
if (client.price === null || client.price === undefined) return '';
var currency = client.currency === '$' ? 'US$' : text(client.currency);
return currency + Number(client.price).toFixed(2) + (cycle ? ' / ' + cycle : '');
}
function reportPeriod(kind, eventTime) {
var current = beijing(eventTime);
var end = new Date(Date.UTC(current.getUTCFullYear(), current.getUTCMonth(), current.getUTCDate()));
if (kind === 'DReport') return dayString(new Date(end.getTime() - 86400000));
if (kind === 'WReport') {
var weekday = end.getUTCDay() || 7;
var monday = new Date(end.getTime() - (weekday - 1 + 7) * 86400000);
return dayString(monday) + ' — ' + dayString(new Date(monday.getTime() + 6 * 86400000));
}
var previous = new Date(Date.UTC(current.getUTCFullYear(), current.getUTCMonth() - 1, 1));
return previous.getUTCFullYear() + ' 年 ' + (previous.getUTCMonth() + 1) + ' 月 · 自然月';
}
function loginDevice(userAgent) {
var browser = /Edg\//.test(userAgent) ? 'Edge' : /Chrome\//.test(userAgent) ? 'Chrome' : /Firefox\//.test(userAgent) ? 'Firefox' : /Safari\//.test(userAgent) ? 'Safari' : '';
var os = /Android/.test(userAgent) ? 'Android' : /iPhone|iPad/.test(userAgent) ? 'iOS' : /Windows/.test(userAgent) ? 'Windows' : /Macintosh|Mac OS X/.test(userAgent) ? 'macOS' : /Linux/.test(userAgent) ? 'Linux' : '';
return browser && os ? browser + ' / ' + os : userAgent;
}
function formatEvent(event) {
var kind = text(event.event);
var clients = Array.isArray(event.clients) ? event.clients : [];
learnClients(clients);
var message = typeof event.message === 'object' && event.message !== null ? JSON.stringify(event.message) : text(event.message);
var title = '📨 通知';
var intro = '';
var blocks = [];
var note = '';
if (kind === 'Offline' || kind === 'Online') {
title = kind === 'Offline' ? '🔴 节点离线' : '🟢 节点上线';
blocks = clients.map(function (c) { return clientBlock(c); });
} else if (kind === 'Expire') {
title = '⏳ 到期提醒';
message.split(/\r?\n/).filter(Boolean).forEach(function (line) {
var match = line.match(/^\s*[•·-]?\s*(.+?)\s+\((-?\d+)d\)\s*$/);
if (!match) { blocks = blocks.concat(plainBlocks(line)); return; }
var due = beijing(event.time);
due.setUTCDate(due.getUTCDate() + Number(match[2]));
blocks.push(strong(nodeName(match[1])) + '\n' + pad(due.getUTCMonth() + 1) + '月' + pad(due.getUTCDate()) + '日到期 · 剩余 ' + Number(match[2]) + ' 天');
});
intro = blocks.length ? '即将到期 · ' + blocks.length + ' 台服务器' : '';
} else if (kind === 'Renew') {
title = '🔄 续费通知';
message.split(/\r?\n/).filter(Boolean).forEach(function (line) {
var match = line.match(/^\s*[•·-]?\s*(.+?)\s+until\s+(\d{4}-\d{2}-\d{2})\s*$/);
if (!match) { blocks = blocks.concat(plainBlocks(line)); return; }
var client = clients.filter(function (c) { return clientName(c) === match[1]; })[0] || {name: match[1]};
var next = match[2].replace(/-/g, '.');
var previous = client.expired_at ? dayString(beijing(client.expired_at)) : '';
var dates = previous ? previous + ' → ' + next : '新到期日期 · ' + next;
blocks.push(clientBlock(client, dates) + (billing(client) ? '\n' + esc(billing(client)) : ''));
});
} else if (kind === 'Login') {
title = '🔑 新的面板登录';
var lines = message.split(/\r?\n/);
var login = lines[0].match(/^([^:]+):\s*(.+?)\s+\((.*)\)$/);
if (login) {
var method = ({password: '密码登录', 'password+2fa': '密码与两步验证', oauth: 'OAuth 登录', sso: '单点登录'})[login[1].toLowerCase()] || login[1];
blocks.push(strong(method) + '\n' + esc(login[2]) + (login[3] && login[3] !== 'unknown' ? ' · ' + esc(login[3]) : ''));
if (lines.length > 1) blocks = blocks.concat(plainBlocks(loginDevice(lines.slice(1).join('\n'))));
} else blocks = plainBlocks(message);
} else if (kind === 'Alert') {
title = '⚠️ 资源告警';
blocks = clients.map(function (c) { return clientBlock(c); });
blocks = blocks.concat(plainBlocks('触发规则 · ' + message));
} else if (kind === 'Traffic') {
title = '🟠 流量预警';
var traffic = message.match(/used\s+([\d.]+)%\s*\(([\d.]+)\s*([KMGTPE]?i?B)\s*\/\s*([\d.]+)\s*([KMGTPE]?i?B)\),\s*type=(\w+)/i);
if (traffic) {
var used = byteValue(traffic[2], traffic[3]);
var limit = byteValue(traffic[4], traffic[5]);
if (clients[0] && Number(clients[0].traffic_limit) > 0) limit = Number(clients[0].traffic_limit);
var scale = scaleFor(limit);
var pct = limit > 0 ? used / limit * 100 : Number(traffic[1]);
if (pct >= 100 || Number(traffic[1]) >= 100) title = used > limit ? '🔴 流量超额' : '🔴 流量已达上限';
intro = clients.map(function (c) { return strong(nodeName(c)); }).join('\n');
var difference = used > limit ? '超出 ' + volume(used - limit, scale) : '剩余 ' + volume(Math.max(0, limit - used), scale);
blocks.push(strong(percentage(pct) + '% 已使用') + '\n' + volume(used, scale) + ' / ' + volume(limit, scale) + ' ' + scale.unit + '\n' + difference + ' ' + scale.unit + ' · ' + esc(modeLabel(traffic[6])));
} else {
blocks = clients.map(function (c) { return clientBlock(c); }).concat(plainBlocks(message));
}
} else if (kind === 'DReport' || kind === 'WReport' || kind === 'MReport') {
title = ({DReport: '📊 昨日流量', WReport: '📈 上周流量', MReport: '📅 月度流量'})[kind];
intro = reportPeriod(kind, event.time);
var reportClients = clients.slice().sort(function (a, b) { return clientName(b).length - clientName(a).length; });
// 1.4.3 joins report rows with a retirement notice and no trailing newline.
var cleaned = message.replace(/Komari 内置的流量报告即将在[^\n]*\n?/g, '').replace(/Komari's built-in traffic reports will be removed[^\n]*?as soon as possible\./g, '');
cleaned.split(/\r?\n/).filter(function (line) { return line.trim(); }).forEach(function (line) {
var match = line.match(/^(.*?)(?:昨日流量|上周流量|上个月流量)[::]\s*([\d.]+)\s*([KMGTPE]?i?B)\s*$/i);
if (!match) { blocks = blocks.concat(plainBlocks(line)); return; }
var client = reportClients.filter(function (c) { return clientName(c) === match[1]; })[0] || {name: match[1]};
var bytes = byteValue(match[2], match[3]);
var unit = scaleFor(bytes);
var amount = volume(bytes, unit) + ' ' + unit.unit;
blocks.push(clientBlock(client, amount + (client.traffic_limit_type ? ' · ' + modeLabel(client.traffic_limit_type) : '')));
});
} else if (kind === 'Test') {
title = '🧪 通知测试';
blocks = clients.map(function (c) { return clientBlock(c); });
blocks = blocks.concat(plainBlocks(message === 'This is a test message from Komari.' ? '通知发送正常。' : message));
} else {
title = text(event.emoji || '📨') + ' ' + (kind || '通知');
blocks = clients.map(function (c) { return clientBlock(c); }).concat(plainBlocks(message));
}
if (!blocks.length) blocks.push(esc(message || '暂无附加信息'));
return paginate(title, intro, blocks, note, clockString(event.time), kind !== 'Offline' && kind !== 'Online');
}
function paginate(title, intro, blocks, note, timestamp, quoted) {
var groups = []; var current = [];
blocks.forEach(function (block) {
// Fields from existing nodes fit comfortably; preserve unbounded custom messages in safe text chunks.
if (block.length > 2600) {
var plain = block.replace(/<[^>]*>/g, '').replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&');
plainBlocks(plain).forEach(function (part) {
if (current.join('\n\n').length + part.length > 2600) { groups.push(current); current = []; }
current.push(part);
});
} else {
if (current.length && current.join('\n\n').length + block.length > 2600) { groups.push(current); current = []; }
current.push(block);
}
});
if (current.length) groups.push(current);
return groups.map(function (group, index) {
var heading = strong(title) + (groups.length > 1 ? ' <i>· ' + (index + 1) + '/' + groups.length + '</i>' : '');
var content = group.join('\n\n');
if (quoted !== false) content = '<blockquote>' + content + '</blockquote>';
return heading + (intro ? '\n\n' + intro : '') + '\n\n' + content + (note ? '\n\n<i>' + esc(note) + '</i>' : '') + '\n\n🕒 ' + esc(timestamp);
});
}
function sendPages(pages) {
var config = KOMARI_TG_CONFIG;
if (!config.bot_token || !config.chat_id || !/^https:\/\//.test(config.endpoint || '')) throw new Error('Telegram configuration unavailable');
var endpoint = config.endpoint + config.bot_token + '/sendMessage';
Object.keys(deliveredPages).forEach(function (key) { if (Date.now() - deliveredPages[key] > 120000) delete deliveredPages[key]; });
var chain = Promise.resolve(true);
pages.forEach(function (page, index) {
chain = chain.then(function () {
if (deliveredPages[page]) return true;
var payload = {chat_id: config.chat_id, text: page, parse_mode: 'HTML', disable_web_page_preview: true};
if (config.message_thread_id) payload.message_thread_id = Number(config.message_thread_id);
return new Promise(function (resolve) { setTimeout(resolve, index ? 1100 : 0); }).then(function () {
return fetch(endpoint, {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(payload)}).then(function (response) {
return response.json().then(function (body) {
if (!response.ok || !body.ok) throw new Error('Telegram rejected notification; code=' + (body.error_code || response.status));
deliveredPages[page] = Date.now();
if (body.result && typeof console !== 'undefined') {
var types = (body.result.entities || []).map(function (entity) { return entity.type; });
console.log('KOMARI_NOTIFY_V1 ' + JSON.stringify({message_id: body.result.message_id, blockquote: types.indexOf('blockquote') >= 0, bold: types.indexOf('bold') >= 0}));
}
return true;
});
});
}).catch(function () { throw new Error('Telegram notification delivery failed'); });
});
});
return chain;
}
function sendEvent(event) {
return prepareEventRegions(event).then(function () { return sendPages(formatEvent(event)); });
}
function sendMessage(message, title) {
return sendEvent({event: title || '通知', message: message, time: new Date().toISOString(), clients: []});
}
好看捏
感谢,让hermes agent去改我的komari去了
据说下个版本大改,会有很多破坏性的“更新”
@xServ #4 留在1.4.3了,已关闭agent自动更新
太好了,就你了
感谢
先收藏了,假期结束搞一搞
这个好看啊,谢谢啊
感谢分享,样式挺好看