#!/usr/bin/env node /** * WeChat Auto-Reply Daemon v1.1.0 (with FAQ keyword rules) * * Polls the WeChat agent backend for new received messages and auto-replies * according to user-configurable rules. No npm dependencies — pure Node.js. * * Config: * ~/.openclaw/wechat-credentials.json (wechatApiBase) * ~/.openclaw/wechat-auto-reply-config.json (reply rules) * * Runtime: * ~/.openclaw/wechat-auto-reply-state.json (lastCheckTime, repliedIds) * ~/.openclaw/logs/auto-reply.log (stdout/stderr) * ~/.openclaw/wechat-auto-reply.pid (PID file) * * Start: nohup node auto-reply-daemon.js > ~/.openclaw/logs/auto-reply.log 2>&1 & * Stop : kill $(cat ~/.openclaw/wechat-auto-reply.pid) */ 'use strict'; const fs = require('fs'); const os = require('os'); const path = require('path'); const http = require('http'); const https = require('https'); // ------------- Paths ------------- const OPENCLAW_DIR = path.join(os.homedir(), '.openclaw'); const CREDS_FILE = path.join(OPENCLAW_DIR, 'wechat-credentials.json'); const CONFIG_FILE = path.join(OPENCLAW_DIR, 'wechat-auto-reply-config.json'); const STATE_FILE = path.join(OPENCLAW_DIR, 'wechat-auto-reply-state.json'); const PID_FILE = path.join(OPENCLAW_DIR, 'wechat-auto-reply.pid'); const LOG_DIR = path.join(OPENCLAW_DIR, 'logs'); // ------------- Defaults ------------- const DEFAULT_CONFIG = { pollIntervalMs: 10000, maxMessagesPerPoll: 50, onlineCheckEveryNPolls: 6, ignoreTypes: ['system', 'emoji', 'voice', 'image', 'video', 'location'], ignoreWxidPrefixes: ['gh_'], ignoreWxidExact: ['weixin', 'fmessage', 'medianote', 'filehelper'], // FAQ 规则:按顺序匹配,第一条命中的 keyword 所在 rule 的 reply 胜出 // 每条规则:{ keywords: [关键词1, 关键词2, ...], reply: "回复内容" } // 匹配方式:大小写不敏感的子串包含 faq: [], personal: { enabled: true, defaultReply: '您好,我是客服小助手,请问您有什么需要咨询的问题吗?', }, group: { enabled: true, keywords: ['@bot', '@助手', '帮我', '请问', '客服'], defaultReply: '收到,有需要我协助的请详细说明~', }, replyCooldownSecPerWxid: 5, }; // ------------- Logging ------------- function ts() { return new Date().toISOString(); } function log(level, msg, extra) { const line = `[${ts()}] [${level}] ${msg}` + (extra ? ' ' + JSON.stringify(extra) : ''); console.log(line); } const info = (m, e) => log('INFO', m, e); const warn = (m, e) => log('WARN', m, e); const error = (m, e) => log('ERROR', m, e); // ------------- JSON helpers ------------- function readJson(file, fallback) { try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch (e) { if (fallback !== undefined) return fallback; throw e; } } function writeJson(file, obj) { fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, JSON.stringify(obj, null, 2)); } // ------------- HTTP ------------- function httpJson(method, url, body) { return new Promise((resolve, reject) => { const u = new URL(url); const lib = u.protocol === 'https:' ? https : http; const payload = body ? Buffer.from(JSON.stringify(body)) : null; const req = lib.request({ hostname: u.hostname, port: u.port || (u.protocol === 'https:' ? 443 : 80), path: u.pathname + u.search, method, headers: Object.assign( { 'Accept': 'application/json' }, payload ? { 'Content-Type': 'application/json', 'Content-Length': payload.length } : {}, ), timeout: 15000, }, (res) => { const chunks = []; res.on('data', c => chunks.push(c)); res.on('end', () => { const text = Buffer.concat(chunks).toString('utf8'); try { resolve({ status: res.statusCode, json: text ? JSON.parse(text) : null, text }); } catch (e) { resolve({ status: res.statusCode, json: null, text }); } }); }); req.on('error', reject); req.on('timeout', () => { req.destroy(new Error('http timeout')); }); if (payload) req.write(payload); req.end(); }); } // ------------- Load config ------------- function loadApiBase() { const creds = readJson(CREDS_FILE); if (!creds || !creds.wechatApiBase) throw new Error('wechatApiBase missing in ' + CREDS_FILE); return creds.wechatApiBase.replace(/\/$/, ''); } function loadConfig() { const userCfg = readJson(CONFIG_FILE, {}); return Object.assign({}, DEFAULT_CONFIG, userCfg, { personal: Object.assign({}, DEFAULT_CONFIG.personal, userCfg.personal || {}), group: Object.assign({}, DEFAULT_CONFIG.group, userCfg.group || {}), ignoreTypes: (userCfg.ignoreTypes || DEFAULT_CONFIG.ignoreTypes).slice(), ignoreWxidPrefixes: (userCfg.ignoreWxidPrefixes || DEFAULT_CONFIG.ignoreWxidPrefixes).slice(), ignoreWxidExact: (userCfg.ignoreWxidExact || DEFAULT_CONFIG.ignoreWxidExact).slice(), faq: Array.isArray(userCfg.faq) ? userCfg.faq.slice() : DEFAULT_CONFIG.faq.slice(), }); } // Match content against faq rules. Returns matched reply string or null. function matchFaq(content, faqRules) { if (!Array.isArray(faqRules) || faqRules.length === 0) return null; const lower = String(content || '').toLowerCase(); for (const rule of faqRules) { if (!rule || !Array.isArray(rule.keywords) || !rule.reply) continue; for (const kw of rule.keywords) { if (!kw) continue; if (lower.includes(String(kw).toLowerCase())) { return { reply: rule.reply, matchedKeyword: kw }; } } } return null; } function loadState() { return readJson(STATE_FILE, { lastCheckTime: '', recentReplies: {} }); } function saveState(s) { writeJson(STATE_FILE, s); } // ------------- Filters ------------- function shouldIgnoreMsg(msg, cfg) { if (msg.direction && msg.direction !== 'received') return 'not-received'; if (cfg.ignoreTypes.includes(msg.type)) return 'ignored-type:' + msg.type; if (cfg.ignoreWxidExact.includes(msg.fromWxid)) return 'ignored-wxid:' + msg.fromWxid; for (const p of cfg.ignoreWxidPrefixes) { if (msg.fromWxid && msg.fromWxid.startsWith(p)) return 'ignored-prefix:' + p; } if (!msg.content) return 'empty-content'; return null; } function isGroupChat(fromWxid) { return typeof fromWxid === 'string' && fromWxid.endsWith('@chatroom'); } // Returns { reply, source } or null. source is 'faq' | 'default'. function pickReply(msg, cfg) { const content = msg.content || ''; if (isGroupChat(msg.fromWxid)) { if (!cfg.group.enabled) return null; const lower = content.toLowerCase(); const triggered = cfg.group.keywords.some(k => lower.includes(String(k).toLowerCase())); if (!triggered) return null; const faqHit = matchFaq(content, cfg.faq); if (faqHit) return { reply: faqHit.reply, source: 'faq:' + faqHit.matchedKeyword }; return { reply: cfg.group.defaultReply, source: 'group-default' }; } if (!cfg.personal.enabled) return null; const faqHit = matchFaq(content, cfg.faq); if (faqHit) return { reply: faqHit.reply, source: 'faq:' + faqHit.matchedKeyword }; return { reply: cfg.personal.defaultReply, source: 'personal-default' }; } function inCooldown(state, wxid, cooldownSec) { const lastTs = state.recentReplies[wxid]; if (!lastTs) return false; return (Date.now() - new Date(lastTs).getTime()) / 1000 < cooldownSec; } // ------------- Core loop ------------- let pollCount = 0; let running = true; async function pollOnce(apiBase, cfg, state) { pollCount++; if (pollCount === 1 || pollCount % cfg.onlineCheckEveryNPolls === 0) { try { const r = await httpJson('POST', apiBase + '/login/check-online', {}); if (!r.json || r.json.data !== true) { warn('wechat offline, skipping this poll', { status: r.status, body: r.text && r.text.slice(0, 200) }); return; } } catch (e) { warn('check-online failed, skipping', { error: e.message }); return; } } const since = state.lastCheckTime || ''; const qs = new URLSearchParams({ limit: String(cfg.maxMessagesPerPoll), direction: 'received', }); if (since) qs.set('since', since); let resp; try { resp = await httpJson('GET', apiBase + '/messages?' + qs.toString()); } catch (e) { error('get-messages failed', { error: e.message }); return; } if (!resp.json || !Array.isArray(resp.json.data)) { warn('get-messages unexpected response', { status: resp.status, body: resp.text && resp.text.slice(0, 300) }); return; } const messages = resp.json.data.slice().sort((a, b) => String(a.timestamp).localeCompare(String(b.timestamp))); if (messages.length === 0) return; info(`received ${messages.length} new message(s)`); let maxTs = state.lastCheckTime; for (const msg of messages) { if (msg.timestamp && String(msg.timestamp) > String(maxTs || '')) maxTs = msg.timestamp; const skipReason = shouldIgnoreMsg(msg, cfg); if (skipReason) { info('skip', { from: msg.fromWxid, type: msg.type, reason: skipReason }); continue; } if (inCooldown(state, msg.fromWxid, cfg.replyCooldownSecPerWxid)) { info('cooldown', { from: msg.fromWxid }); continue; } const pick = pickReply(msg, cfg); if (!pick) { info('no-reply-rule', { from: msg.fromWxid, group: isGroupChat(msg.fromWxid) }); continue; } try { const send = await httpJson('POST', apiBase + '/message/send-text', { toWxid: msg.fromWxid, content: pick.reply, ats: '', }); if (send.json && send.json.ret === 200) { info('replied', { to: msg.fromWxid, nick: msg.nickName, source: pick.source, content: pick.reply }); state.recentReplies[msg.fromWxid] = new Date().toISOString(); } else { warn('send failed', { to: msg.fromWxid, status: send.status, body: send.text && send.text.slice(0, 200) }); } } catch (e) { error('send exception', { to: msg.fromWxid, error: e.message }); } } state.lastCheckTime = maxTs || new Date().toISOString(); // Prune old recentReplies (> 1 day) to keep file small const cutoff = Date.now() - 86400_000; for (const k of Object.keys(state.recentReplies)) { if (new Date(state.recentReplies[k]).getTime() < cutoff) delete state.recentReplies[k]; } saveState(state); } async function mainLoop() { const apiBase = loadApiBase(); info('daemon start', { apiBase, pid: process.pid, nodeVersion: process.version }); fs.mkdirSync(LOG_DIR, { recursive: true }); fs.writeFileSync(PID_FILE, String(process.pid)); const cfg = loadConfig(); info('config loaded', { pollIntervalMs: cfg.pollIntervalMs, cooldownSec: cfg.replyCooldownSecPerWxid, faqRules: cfg.faq.length, personal: cfg.personal.enabled, group: cfg.group.enabled + ' keywords=' + cfg.group.keywords.length, }); let state = loadState(); info('state loaded', { lastCheckTime: state.lastCheckTime || '(empty)' }); while (running) { const liveCfg = loadConfig(); // hot-reload each loop try { await pollOnce(apiBase, liveCfg, state); } catch (e) { error('poll exception', { error: e.message, stack: e.stack }); } await new Promise(r => setTimeout(r, liveCfg.pollIntervalMs)); } info('daemon stopping'); try { fs.unlinkSync(PID_FILE); } catch (e) {} process.exit(0); } // ------------- Signal handlers ------------- function shutdown(sig) { info(`signal received: ${sig}`); running = false; } process.on('SIGTERM', () => shutdown('SIGTERM')); process.on('SIGINT', () => shutdown('SIGINT')); process.on('uncaughtException', e => { error('uncaughtException', { error: e.message, stack: e.stack }); }); process.on('unhandledRejection', e => { error('unhandledRejection', { error: String(e) }); }); mainLoop().catch(e => { error('fatal', { error: e.message, stack: e.stack }); try { fs.unlinkSync(PID_FILE); } catch {} process.exit(1); });