| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314 |
- #!/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);
- });
|