Files
xianyu-hunter-hermes-skill/project/xianyu-hunter/lib/notifier.mjs
T

150 lines
6.7 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { spawn } from 'child_process';
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,
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 { ...DEFAULT_CONFIG, ...cfg, targetOptions: HERMES_TARGET_OPTIONS, channels: Array.isArray(cfg.channels) ? cfg.channels : DEFAULT_CONFIG.channels };
} catch {
return { ...DEFAULT_CONFIG, targetOptions: HERMES_TARGET_OPTIONS };
}
}
export function saveNotifyConfig(config = {}) {
const normalized = {
enabled: config.enabled !== false,
notifyOnlyGood: config.notifyOnlyGood !== false,
minScore: Math.max(0, Math.min(100, Number(config.minScore ?? 75))),
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',
})),
};
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 buildNotificationText(task, summary) {
const label = { recommend: '推荐', caution: '谨慎', reject: '排除' };
const items = summary.topItems
.filter(i => i.verdict === 'recommend' || i.score >= summary.minScore)
.slice(0, 5);
const lines = [];
lines.push(`🐟 闲鱼任务「${task.name || task.id}」发现 ${summary.recommendedCount} 个推荐候选`);
lines.push(`候选/推荐/谨慎/排除:${summary.total}/${summary.recommendedCount}/${summary.cautionCount}/${summary.rejectedCount}`);
lines.push('');
for (const item of items) {
lines.push(`${item.score}分|${label[item.verdict] || item.verdict}${item.title || ''}`);
lines.push(`价格:${item.price || '-'}|地区:${item.location || '-'}`);
if (item.reason) lines.push(`判断:${item.reason}`);
if (item.href) lines.push(item.href);
lines.push('');
}
if (summary.reportPath) lines.push(`报告:${summary.reportPath}`);
return lines.join('\n').trim();
}
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 message = buildNotificationText(task, { ...summary, minScore });
const results = [];
for (const ch of (cfg.channels || []).filter(c => c.enabled)) {
try {
if (ch.type === 'hermes-send-message' || ch.type === 'hermes-webhook') {
if (!ch.target) throw new Error('请选择 Hermes 通知目标');
await runHermesSendMessage(ch.target, message);
} else if (ch.type === 'bark') {
const base = (ch.url || '').replace(/\/$/, '');
const endpoint = ch.token ? `${base}/${encodeURIComponent(ch.token)}` : base;
await postJson(endpoint, { title: `闲鱼发现候选:${task.name || task.id}`, body: message, group: 'xianyu-hunter' });
} else if (ch.type === 'pushplus') {
await postJson(ch.url || 'https://www.pushplus.plus/send', { token: ch.token, title: `闲鱼发现候选:${task.name || task.id}`, content: message, template: 'txt' });
} else {
await postJson(ch.url, { title: `闲鱼发现候选:${task.name || task.id}`, message, taskId: task.id });
}
results.push({ id: ch.id, name: ch.name, ok: true });
} catch (err) {
results.push({ id: ch.id, name: ch.name, ok: false, error: err.message });
}
}
return { ok: results.every(r => r.ok), skipped: false, message, results };
}