feat: 新增Telegram通知支持,新增手动发送日报功能
1. 新增Telegram机器人通知配置项和实现逻辑 2. 重构通知发送逻辑为统一的sendAllNotifications方法 3. 调整日报定时任务时间为23:30,优化日报内容 4. 后台页面新增手动发送日报按钮和相关接口 5. 优化通知提示文案,兼容多通知渠道
This commit is contained in:
@@ -8,7 +8,12 @@ LOGIN_PASSWORD=你的密码
|
|||||||
# 选填 - 企业微信群机器人 Webhook URL
|
# 选填 - 企业微信群机器人 Webhook URL
|
||||||
WECOM_WEBHOOK_URL=https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=你的key
|
WECOM_WEBHOOK_URL=https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=你的key
|
||||||
|
|
||||||
# 选填 - 电费余额预警阈值(低于此值时发送企业微信通知,设为0或留空关闭)
|
# 选填 - Telegram 机器人 Token(从 @BotFather 获取)
|
||||||
|
TELEGRAM_BOT_TOKEN=1234567890:ABCdefGHIjklmNOPqrstUVwxyz
|
||||||
|
# 选填 - 接收通知的 Telegram 用户 ID(从 @userinfobot 获取)
|
||||||
|
TELEGRAM_CHAT_ID=123456789
|
||||||
|
|
||||||
|
# 选填 - 电费余额预警阈值(低于此值时发送通知,设为0或留空关闭)
|
||||||
ALERT_THRESHOLD=20
|
ALERT_THRESHOLD=20
|
||||||
|
|
||||||
# 选填 - Cookie 签名密钥
|
# 选填 - Cookie 签名密钥
|
||||||
|
|||||||
@@ -86,7 +86,21 @@ export default function Dashboard({ onLogout }) {
|
|||||||
const res = await fetch('/api/test-notify');
|
const res = await fetch('/api/test-notify');
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
message.success('测试消息已发送,请查看企业微信');
|
message.success('测试消息已发送,请检查通知渠道');
|
||||||
|
} else {
|
||||||
|
message.error('发送失败');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
message.error('发送失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSendReport = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/send-report');
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.success) {
|
||||||
|
message.success('日报已发送');
|
||||||
} else {
|
} else {
|
||||||
message.error('发送失败');
|
message.error('发送失败');
|
||||||
}
|
}
|
||||||
@@ -333,6 +347,18 @@ body { margin: 0; }
|
|||||||
>
|
>
|
||||||
测试通知
|
测试通知
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleSendReport}
|
||||||
|
size="small"
|
||||||
|
style={{
|
||||||
|
border: '1px solid var(--border-light)',
|
||||||
|
borderRadius: 20,
|
||||||
|
color: 'var(--text-secondary)',
|
||||||
|
fontSize: 13,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
发送日报
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
icon={<LogoutOutlined />}
|
icon={<LogoutOutlined />}
|
||||||
onClick={handleLogout}
|
onClick={handleLogout}
|
||||||
|
|||||||
85
server.js
85
server.js
@@ -54,10 +54,21 @@ if (!WECOM_WEBHOOK_URL) {
|
|||||||
console.warn('警告: 环境变量 WECOM_WEBHOOK_URL 未设置,企业微信通知功能将不可用');
|
console.warn('警告: 环境变量 WECOM_WEBHOOK_URL 未设置,企业微信通知功能将不可用');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const TELEGRAM_BOT_TOKEN = process.env.TELEGRAM_BOT_TOKEN;
|
||||||
|
const TELEGRAM_CHAT_ID = process.env.TELEGRAM_CHAT_ID;
|
||||||
|
|
||||||
|
if (TELEGRAM_BOT_TOKEN && TELEGRAM_CHAT_ID) {
|
||||||
|
console.log('Telegram 机器人通知已启用');
|
||||||
|
} else if (TELEGRAM_BOT_TOKEN || TELEGRAM_CHAT_ID) {
|
||||||
|
console.warn('警告: 需要同时设置 TELEGRAM_BOT_TOKEN 和 TELEGRAM_CHAT_ID 才能使用 Telegram 通知');
|
||||||
|
} else {
|
||||||
|
console.log('Telegram 机器人通知未配置');
|
||||||
|
}
|
||||||
|
|
||||||
const ALERT_THRESHOLD = parseFloat(process.env.ALERT_THRESHOLD) || 0;
|
const ALERT_THRESHOLD = parseFloat(process.env.ALERT_THRESHOLD) || 0;
|
||||||
|
|
||||||
if (ALERT_THRESHOLD > 0) {
|
if (ALERT_THRESHOLD > 0) {
|
||||||
console.log(`电费预警: 当余额低于 ¥${ALERT_THRESHOLD} 时将发送企业微信通知`);
|
console.log(`电费预警: 当余额低于 ¥${ALERT_THRESHOLD} 时将发送通知`);
|
||||||
}
|
}
|
||||||
|
|
||||||
let lastAlertedAmount = null;
|
let lastAlertedAmount = null;
|
||||||
@@ -99,6 +110,39 @@ async function sendWecomNotification(content) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function sendTelegramNotification(text) {
|
||||||
|
if (!TELEGRAM_BOT_TOKEN || !TELEGRAM_CHAT_ID) return;
|
||||||
|
const tgText = text
|
||||||
|
.replace(/^## (.+)$/gm, '*$1*')
|
||||||
|
.replace(/^> /gm, '')
|
||||||
|
.trim();
|
||||||
|
try {
|
||||||
|
const res = await fetch(`https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
chat_id: TELEGRAM_CHAT_ID,
|
||||||
|
text: tgText,
|
||||||
|
parse_mode: 'Markdown',
|
||||||
|
disable_web_page_preview: true,
|
||||||
|
})
|
||||||
|
});
|
||||||
|
const body = await res.json();
|
||||||
|
if (!body.ok) {
|
||||||
|
console.error(`Telegram 通知发送失败: ${body.description}`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Telegram 通知发送失败:', err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendAllNotifications(markdownContent, plainText) {
|
||||||
|
await Promise.all([
|
||||||
|
sendWecomNotification(markdownContent),
|
||||||
|
sendTelegramNotification(plainText || markdownContent),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
function getLocalDateStr(d) {
|
function getLocalDateStr(d) {
|
||||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||||
}
|
}
|
||||||
@@ -156,7 +200,7 @@ async function collectData() {
|
|||||||
const data = await fetchData();
|
const data = await fetchData();
|
||||||
if (!data) {
|
if (!data) {
|
||||||
console.log(`[${new Date().toLocaleString()}] 采集失败: 接口请求异常`);
|
console.log(`[${new Date().toLocaleString()}] 采集失败: 接口请求异常`);
|
||||||
await sendWecomNotification(
|
await sendAllNotifications(
|
||||||
'## 电费采集异常\n\n' +
|
'## 电费采集异常\n\n' +
|
||||||
`> 时间:${new Date().toLocaleString('zh-CN')}\n\n` +
|
`> 时间:${new Date().toLocaleString('zh-CN')}\n\n` +
|
||||||
'电费接口请求失败,请检查网络或 Cookie 是否过期。'
|
'电费接口请求失败,请检查网络或 Cookie 是否过期。'
|
||||||
@@ -189,7 +233,7 @@ async function checkThresholdAndAlert(amount) {
|
|||||||
if (lastAlertedAmount !== null && amount >= lastAlertedAmount) return;
|
if (lastAlertedAmount !== null && amount >= lastAlertedAmount) return;
|
||||||
lastAlertedAmount = amount;
|
lastAlertedAmount = amount;
|
||||||
console.log(`[预警] 余额 ¥${amount} 低于阈值 ¥${ALERT_THRESHOLD}`);
|
console.log(`[预警] 余额 ¥${amount} 低于阈值 ¥${ALERT_THRESHOLD}`);
|
||||||
await sendWecomNotification(
|
await sendAllNotifications(
|
||||||
'## 电费余额预警\n\n' +
|
'## 电费余额预警\n\n' +
|
||||||
`> 时间:${new Date().toLocaleString('zh-CN')}\n\n` +
|
`> 时间:${new Date().toLocaleString('zh-CN')}\n\n` +
|
||||||
`当前余额 **¥${amount.toFixed(2)}**\n\n` +
|
`当前余额 **¥${amount.toFixed(2)}**\n\n` +
|
||||||
@@ -370,16 +414,15 @@ cron.schedule('0 * * * *', async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
async function sendDailyReport() {
|
async function sendDailyReport() {
|
||||||
const yesterday = new Date();
|
const now = new Date();
|
||||||
yesterday.setDate(yesterday.getDate() - 1);
|
const dateStr = getLocalDateStr(now);
|
||||||
const dateStr = getLocalDateStr(yesterday);
|
|
||||||
|
|
||||||
const records = getRecordsByDate(dateStr);
|
const records = getRecordsByDate(dateStr);
|
||||||
if (records.length < 1) {
|
if (records.length < 1) {
|
||||||
await sendWecomNotification(
|
await sendAllNotifications(
|
||||||
'## 电费日报\n\n' +
|
'## 电费日报\n\n' +
|
||||||
`> 日期:${dateStr}\n\n` +
|
`> 日期:${dateStr}\n\n` +
|
||||||
`昨日无数据记录`
|
`今日无数据记录`
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -388,7 +431,7 @@ async function sendDailyReport() {
|
|||||||
const first = sorted[0];
|
const first = sorted[0];
|
||||||
const last = sorted[sorted.length - 1];
|
const last = sorted[sorted.length - 1];
|
||||||
|
|
||||||
const dayBefore = new Date(yesterday);
|
const dayBefore = new Date(now);
|
||||||
dayBefore.setDate(dayBefore.getDate() - 1);
|
dayBefore.setDate(dayBefore.getDate() - 1);
|
||||||
const dayBeforeRecords = getRecordsByDate(getLocalDateStr(dayBefore));
|
const dayBeforeRecords = getRecordsByDate(getLocalDateStr(dayBefore));
|
||||||
const dayBeforeLast = dayBeforeRecords.length > 0 ? dayBeforeRecords[dayBeforeRecords.length - 1] : null;
|
const dayBeforeLast = dayBeforeRecords.length > 0 ? dayBeforeRecords[dayBeforeRecords.length - 1] : null;
|
||||||
@@ -399,14 +442,15 @@ async function sendDailyReport() {
|
|||||||
const cost = Math.round(Math.max(0, baseAmount - last.amount) * 100) / 100;
|
const cost = Math.round(Math.max(0, baseAmount - last.amount) * 100) / 100;
|
||||||
|
|
||||||
const hoursSpan = (last.timestamp - first.timestamp) / (1000 * 60 * 60);
|
const hoursSpan = (last.timestamp - first.timestamp) / (1000 * 60 * 60);
|
||||||
const avgPower = hoursSpan > 0 ? Math.round((usage / hoursSpan) * 1000) / 1000 : 0;
|
const reportHours = hoursSpan > 0 ? hoursSpan : Math.max(1, (Date.now() - first.timestamp) / 3600000);
|
||||||
|
const avgPower = Math.round((usage / reportHours) * 1000) / 1000;
|
||||||
|
|
||||||
const latest = getLatestRecord();
|
const latest = getLatestRecord();
|
||||||
|
|
||||||
let content = '## 电费日报\n\n';
|
let content = '## 电费日报\n\n';
|
||||||
content += `> 日期:${dateStr}\n\n`;
|
content += `> 日期:${dateStr}\n\n`;
|
||||||
content += `**昨日用电**:${usage.toFixed(2)} 度\n`;
|
content += `**今日用电**:${usage.toFixed(2)} 度\n`;
|
||||||
content += `**昨日电费**:¥${cost.toFixed(2)}\n`;
|
content += `**今日电费**:¥${cost.toFixed(2)}\n`;
|
||||||
content += `**平均功率**:${avgPower.toFixed(3)} kW\n`;
|
content += `**平均功率**:${avgPower.toFixed(3)} kW\n`;
|
||||||
content += `**数据记录**:${sorted.length} 条\n`;
|
content += `**数据记录**:${sorted.length} 条\n`;
|
||||||
if (latest) {
|
if (latest) {
|
||||||
@@ -414,11 +458,11 @@ async function sendDailyReport() {
|
|||||||
content += `**当前剩余余额**:¥${latest.amount.toFixed(2)}\n`;
|
content += `**当前剩余余额**:¥${latest.amount.toFixed(2)}\n`;
|
||||||
}
|
}
|
||||||
|
|
||||||
await sendWecomNotification(content);
|
await sendAllNotifications(content);
|
||||||
}
|
}
|
||||||
|
|
||||||
cron.schedule('0 0 * * *', async () => {
|
cron.schedule('30 23 * * *', async () => {
|
||||||
console.log(`[定时任务] 发送昨日用电报告...`);
|
console.log(`[定时任务] 发送今日用电报告...`);
|
||||||
await sendDailyReport();
|
await sendDailyReport();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -428,12 +472,17 @@ app.get('/api/trigger-collect', requireAuth, async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/test-notify', requireAuth, async (req, res) => {
|
app.get('/api/test-notify', requireAuth, async (req, res) => {
|
||||||
await sendWecomNotification(
|
await sendAllNotifications(
|
||||||
'## 电费监控测试消息\n\n' +
|
'## 电费监控测试消息\n\n' +
|
||||||
`> 时间:${new Date().toLocaleString('zh-CN')}\n\n` +
|
`> 时间:${new Date().toLocaleString('zh-CN')}\n\n` +
|
||||||
'如果收到此消息,说明企业微信通知配置正常。'
|
'如果收到此消息,说明推送通知配置正常。'
|
||||||
);
|
);
|
||||||
res.json({ success: true, message: '测试消息已发送,请查看企业微信' });
|
res.json({ success: true, message: '测试消息已发送,请检查通知渠道' });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/send-report', requireAuth, async (req, res) => {
|
||||||
|
await sendDailyReport();
|
||||||
|
res.json({ success: true, message: '日报已发送' });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('*', (req, res) => {
|
app.get('*', (req, res) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user