import fs from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; import { spawn } from 'child_process'; import { buildItemLinks } from './links.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const ROOT = path.join(__dirname, '..'); const DATA_DIR = path.join(ROOT, 'data'); const NOTIFY_FILE = path.join(DATA_DIR, 'notify-config.json'); const DEFAULT_CONFIG = { enabled: true, notifyOnlyGood: true, minScore: 75, perItem: true, maxNotifyItems: 10, channels: [ { id: 'feishu-default', type: 'hermes-send-message', name: '飞书当前会话', enabled: true, target: 'feishu:oc_3e963a150d98f254d8f38881bcfa1812' }, { id: 'weixin-default', type: 'hermes-send-message', name: '微信默认会话', enabled: false, target: 'weixin' }, ], }; const HERMES_TARGET_OPTIONS = [ { value: 'feishu', label: '飞书 Home' }, { value: 'feishu:oc_3e963a150d98f254d8f38881bcfa1812', label: '飞书当前 DM' }, { value: 'weixin', label: '微信 Home' }, { value: 'weixin:o9cq809DWSYDlvbA9pj1akncjrhY@im.wechat', label: '微信 DM 1' }, { value: 'weixin:o9cq808qCd1W-cIDo0iWjteE5lHY@im.wechat', label: '微信 DM 2' }, { value: 'qqbot', label: 'QQ 机器人 Home(如已配置)' }, ]; export function getNotifyConfig() { try { if (!fs.existsSync(NOTIFY_FILE)) return { ...DEFAULT_CONFIG, targetOptions: HERMES_TARGET_OPTIONS }; const cfg = JSON.parse(fs.readFileSync(NOTIFY_FILE, 'utf-8')); return normalizeNotifyConfig({ ...DEFAULT_CONFIG, ...cfg, channels: Array.isArray(cfg.channels) ? cfg.channels : DEFAULT_CONFIG.channels }, true); } catch { return { ...DEFAULT_CONFIG, targetOptions: HERMES_TARGET_OPTIONS }; } } function normalizeNotifyConfig(config = {}, includeTargetOptions = false) { const normalized = { enabled: config.enabled !== false, notifyOnlyGood: config.notifyOnlyGood !== false, minScore: Math.max(0, Math.min(100, Number(config.minScore ?? 75))), perItem: config.perItem !== false, maxNotifyItems: Math.max(1, Math.min(50, Number(config.maxNotifyItems ?? 10) || 10)), channels: (config.channels || []).map((c, i) => ({ id: c.id || `${c.type || 'channel'}-${Date.now()}-${i}`, type: c.type || 'hermes-send-message', name: c.name || c.type || '通知通道', enabled: !!c.enabled, url: c.url || '', token: c.token || '', target: c.target || c.platform || 'feishu', })), }; return includeTargetOptions ? { ...normalized, targetOptions: HERMES_TARGET_OPTIONS } : normalized; } export function saveNotifyConfig(config = {}) { const normalized = normalizeNotifyConfig(config); fs.mkdirSync(DATA_DIR, { recursive: true }); fs.writeFileSync(NOTIFY_FILE, JSON.stringify(normalized, null, 2), 'utf-8'); return { ...normalized, targetOptions: HERMES_TARGET_OPTIONS }; } export function getHermesTargetOptions() { return HERMES_TARGET_OPTIONS; } function selectNotificationItems(summary, minScore) { return (summary.topItems || []) .filter(i => i.verdict === 'recommend' || i.score >= minScore) .slice(0, Number(summary.maxNotifyItems || 10)); } function buildItemMarkdownLink(item) { const links = buildItemLinks(item); const url = links.markdownUrl || links.primaryUrl || item.href || item.url || ''; if (!url) return ''; // Feishu 对裸 URL 的自动识别不稳定;显式 Markdown 链接更容易渲染为可点链接。 return `[🔗 打开闲鱼商品](${url})`; } function buildNotificationMessages(task, summary) { const label = { recommend: '推荐', caution: '谨慎', reject: '排除' }; const items = selectNotificationItems(summary, summary.minScore); return items.map((item, index) => { const lines = []; lines.push(`🐟 闲鱼候选 ${index + 1}/${items.length}|${label[item.verdict] || item.verdict}|${item.score}分`); lines.push(`任务:${task.name || task.id}`); lines.push(''); lines.push(`**${item.title || '(无标题)'}**`); lines.push(`价格:${item.price || '-'}|地区:${item.location || '-'}`); if (item.reason) lines.push(`判断:${item.reason}`); if (item.flags?.length) lines.push(`风险:${item.flags.join(';')}`); const link = buildItemMarkdownLink(item); if (link) lines.push(link); const rawUrl = buildItemLinks(item).primaryUrl || item.href || item.url || ''; if (rawUrl) lines.push(`备用链接:${rawUrl}`); return lines.join('\n').trim(); }); } function buildNotificationText(task, summary) { const messages = buildNotificationMessages(task, summary); if (messages.length) return messages.join('\n\n---\n\n'); return `🐟 闲鱼任务「${task.name || task.id}」暂无达到通知阈值的候选`; } async function runHermesSendMessage(target, message) { return new Promise((resolve, reject) => { const script = `import json\nfrom tools.send_message_tool import send_message_tool\nprint(send_message_tool({"action":"send","target":${JSON.stringify(target)},"message":${JSON.stringify(message)}}))\n`; const child = spawn(process.env.HERMES_PYTHON || '/Users/chick/.hermes/hermes-agent/venv/bin/python3', ['-c', script], { cwd: process.env.HERMES_AGENT_DIR || '/Users/chick/.hermes/hermes-agent' }); let stdout = ''; let stderr = ''; const timer = setTimeout(() => { child.kill('SIGKILL'); reject(new Error('Hermes send_message 超时')); }, 60000); child.stdout.on('data', d => { stdout += d.toString(); }); child.stderr.on('data', d => { stderr += d.toString(); }); child.on('error', err => { clearTimeout(timer); reject(err); }); child.on('close', code => { clearTimeout(timer); if (code !== 0) return reject(new Error((stderr || stdout || `Hermes send_message 退出码 ${code}`).slice(0, 500))); try { const parsed = JSON.parse(stdout.trim().split('\n').pop() || '{}'); if (parsed.error) return reject(new Error(parsed.error)); resolve(parsed); } catch { resolve({ ok: true, stdout: stdout.trim(), stderr: stderr.trim() }); } }); }); } async function postJson(url, body) { const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); const text = await res.text(); if (!res.ok) throw new Error(`HTTP ${res.status}: ${text.slice(0, 200)}`); return text; } export async function sendNotifications(task, summary, options = {}) { const cfg = options.config || getNotifyConfig(); const minScore = Number(cfg.minScore ?? 75); const shouldNotify = cfg.enabled !== false && (!cfg.notifyOnlyGood || summary.topItems.some(i => i.verdict === 'recommend' || i.score >= minScore)); if (!shouldNotify && !options.force) { return { ok: true, skipped: true, reason: '没有达到通知条件' }; } const messages = buildNotificationMessages(task, { ...summary, minScore, maxNotifyItems: cfg.maxNotifyItems ?? summary.maxNotifyItems ?? 10 }); const message = buildNotificationText(task, { ...summary, minScore, maxNotifyItems: cfg.maxNotifyItems ?? summary.maxNotifyItems ?? 10 }); const perItem = cfg.perItem !== false; const outboundMessages = perItem && messages.length ? messages : [message]; const results = []; for (const ch of (cfg.channels || []).filter(c => c.enabled)) { for (let i = 0; i < outboundMessages.length; i++) { const outbound = outboundMessages[i]; try { if (ch.type === 'hermes-send-message' || ch.type === 'hermes-webhook') { if (!ch.target) throw new Error('请选择 Hermes 通知目标'); await runHermesSendMessage(ch.target, outbound); } else if (ch.type === 'bark') { const base = (ch.url || '').replace(/\/$/, ''); const endpoint = ch.token ? `${base}/${encodeURIComponent(ch.token)}` : base; await postJson(endpoint, { title: `闲鱼候选 ${i + 1}/${outboundMessages.length}:${task.name || task.id}`, body: outbound, group: 'xianyu-hunter' }); } else if (ch.type === 'pushplus') { await postJson(ch.url || 'https://www.pushplus.plus/send', { token: ch.token, title: `闲鱼候选 ${i + 1}/${outboundMessages.length}:${task.name || task.id}`, content: outbound, template: 'markdown' }); } else { await postJson(ch.url, { title: `闲鱼候选 ${i + 1}/${outboundMessages.length}:${task.name || task.id}`, message: outbound, taskId: task.id, itemIndex: i + 1 }); } results.push({ id: ch.id, name: ch.name, itemIndex: i + 1, ok: true }); } catch (err) { results.push({ id: ch.id, name: ch.name, itemIndex: i + 1, ok: false, error: err.message }); } } } return { ok: results.every(r => r.ok), skipped: false, message, messages: outboundMessages, perItem, results }; }