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

186 lines
8.6 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';
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 };
}