• deerxxx

    效果:

    节点离线/上线:

    图片

    到期提醒/续费通知:

    图片

    面板登录/流量预警:

    图片

    资源告警:

    图片

    流量统计:

    图片

    在 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); }
    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)\),\stype=(\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(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/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: []});
    }

    主楼
  • wslespark

    ^-^ 高级货

    #1
  • ArmaniZzz

    好看捏

    #2
  • SVlP

    感谢,让hermes agent去改我的komari去了

    #3
  • xServ

    据说下个版本大改,会有很多破坏性的“更新”

    #4
  • deerxxx

    留在1.4.3了,已关闭agent自动更新

    #5
  • kuser

    太好了,就你了

    #6
  • BreadKnight

    感谢

    #7
  • 濯梦

    先收藏了,假期结束搞一搞

    #8
  • iTTT

    这个好看啊,谢谢啊

    #9
  • corenuclear

    感谢分享,样式挺好看

    #10
  • sykin

    👍 已用上

    #11

发表回复

登录后回复