/** * 飞书 Webhook 通知模块 * * 功能: * - 发送文本消息 * - 发送截图:截图 → 上传到图床(telegra.ph) → 飞书消息附带图片URL * - 如果图片上传失败,自动降级为文本+文件名 */ const config = require('../config'); const logger = require('./logger'); const path = require('path'); const fs = require('fs'); /** * 发送飞书文本消息(带重试) * 调试模式下跳过发送 * @param {string} text 消息内容 * @returns {Promise} */ async function sendFeishuText(text) { if (process.env.DEBUG === 'true') { logger.info('[调试模式] 跳过飞书通知'); return true; } if (!config.feishu.webhook) { logger.info('未配置 FEISHU_WEBHOOK,跳过飞书通知'); return true; } const payload = { msg_type: 'text', content: { text }, }; return sendPayload(payload); } /** * 统一发送请求(带重试) * @param {object} payload * @returns {Promise} */ async function sendPayload(payload) { const maxRetries = config.retryCount; for (let attempt = 1; attempt <= maxRetries; attempt++) { try { logger.info(`发送飞书通知 (第${attempt}次尝试)...`); const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 10000); const res = await fetch(config.feishu.webhook, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), signal: controller.signal, }); clearTimeout(timeout); const data = await res.text(); logger.info(`飞书响应: ${data}`); return true; } catch (err) { logger.warn(`飞书发送失败 (第${attempt}次): ${err.message}`); if (attempt < maxRetries) { logger.info(`等待 3 秒后重试...`); await new Promise((r) => setTimeout(r, 3000)); } } } logger.error(`飞书通知发送失败,已重试 ${maxRetries} 次`); return false; } /** * 截图并发送到飞书(成功/失败通用) * * 流程: * 1. 截图保存到本地 screenshots/ * 2. 发送飞书文本消息附带截图文件名(纯本地,不上传任何外部服务) * * @param {import('playwright').Page} page * @param {string} taskName 任务名称 * @param {boolean} isSuccess 是否成功 * @param {string} [errorMessage] 失败时的错误信息 */ async function sendScreenshotNotify(page, taskName, isSuccess, errorMessage) { if (process.env.DEBUG === 'true') { logger.info('[调试模式] 跳过飞书截图通知'); return; } // 1. 截图保存本地 const screenshotPath = await takeScreenshot(page, taskName); // 2. 构建文本消息(附带本地文件名) const status = isSuccess ? '✅' : '❌'; const lines = [`${status} ${taskName} ${isSuccess ? '执行成功' : '执行失败'}`]; if (!isSuccess && errorMessage) { lines.push(`错误: ${errorMessage}`); } lines.push(`截图已保存: ${path.basename(screenshotPath)}`); await sendFeishuText(lines.join('\n')); } /** * 截图辅助(本地保存) * @param {import('playwright').Page} page * @param {string} label * @returns {Promise} */ async function takeScreenshot(page, label) { const dir = path.resolve(config.dirs.screenshots); if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }); } const safeLabel = label.replace(/[\\/:*?"<>|]/g, '_'); const timestamp = Date.now(); const filename = `screenshot-${safeLabel}-${timestamp}.png`; const filepath = path.join(dir, filename); try { await page.screenshot({ path: filepath, fullPage: true }); logger.info(`截图已保存: ${filepath}`); } catch (err) { logger.warn(`截图失败: ${err.message}`); } return filepath; } /** * 发送任务完成通知(文本汇总) * @param {object} results 任务执行结果 { taskName: true/false, ... } */ async function notifySuccess(results) { const successCount = Object.values(results).filter(Boolean).length; const totalCount = Object.keys(results).length; const lines = ['✅ 今日任务完成', '']; for (const [name, ok] of Object.entries(results)) { lines.push(`${ok ? '✅' : '❌'} ${name}`); } lines.push(''); lines.push(`完成: ${successCount}/${totalCount}`); await sendFeishuText(lines.join('\n')); } /** * 发送任务失败通知(文本) * @param {string} taskName * @param {string} reason */ async function notifyError(taskName, reason) { const text = `❌ ${taskName} 执行失败\n\n错误原因: ${reason}`; await sendFeishuText(text); } module.exports = { sendFeishuText, sendScreenshotNotify, notifySuccess, notifyError, };