auto-reply-daemon.js 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314
  1. #!/usr/bin/env node
  2. /**
  3. * WeChat Auto-Reply Daemon v1.1.0 (with FAQ keyword rules)
  4. *
  5. * Polls the WeChat agent backend for new received messages and auto-replies
  6. * according to user-configurable rules. No npm dependencies — pure Node.js.
  7. *
  8. * Config:
  9. * ~/.openclaw/wechat-credentials.json (wechatApiBase)
  10. * ~/.openclaw/wechat-auto-reply-config.json (reply rules)
  11. *
  12. * Runtime:
  13. * ~/.openclaw/wechat-auto-reply-state.json (lastCheckTime, repliedIds)
  14. * ~/.openclaw/logs/auto-reply.log (stdout/stderr)
  15. * ~/.openclaw/wechat-auto-reply.pid (PID file)
  16. *
  17. * Start: nohup node auto-reply-daemon.js > ~/.openclaw/logs/auto-reply.log 2>&1 &
  18. * Stop : kill $(cat ~/.openclaw/wechat-auto-reply.pid)
  19. */
  20. 'use strict';
  21. const fs = require('fs');
  22. const os = require('os');
  23. const path = require('path');
  24. const http = require('http');
  25. const https = require('https');
  26. // ------------- Paths -------------
  27. const OPENCLAW_DIR = path.join(os.homedir(), '.openclaw');
  28. const CREDS_FILE = path.join(OPENCLAW_DIR, 'wechat-credentials.json');
  29. const CONFIG_FILE = path.join(OPENCLAW_DIR, 'wechat-auto-reply-config.json');
  30. const STATE_FILE = path.join(OPENCLAW_DIR, 'wechat-auto-reply-state.json');
  31. const PID_FILE = path.join(OPENCLAW_DIR, 'wechat-auto-reply.pid');
  32. const LOG_DIR = path.join(OPENCLAW_DIR, 'logs');
  33. // ------------- Defaults -------------
  34. const DEFAULT_CONFIG = {
  35. pollIntervalMs: 10000,
  36. maxMessagesPerPoll: 50,
  37. onlineCheckEveryNPolls: 6,
  38. ignoreTypes: ['system', 'emoji', 'voice', 'image', 'video', 'location'],
  39. ignoreWxidPrefixes: ['gh_'],
  40. ignoreWxidExact: ['weixin', 'fmessage', 'medianote', 'filehelper'],
  41. // FAQ 规则:按顺序匹配,第一条命中的 keyword 所在 rule 的 reply 胜出
  42. // 每条规则:{ keywords: [关键词1, 关键词2, ...], reply: "回复内容" }
  43. // 匹配方式:大小写不敏感的子串包含
  44. faq: [],
  45. personal: {
  46. enabled: true,
  47. defaultReply: '您好,我是客服小助手,请问您有什么需要咨询的问题吗?',
  48. },
  49. group: {
  50. enabled: true,
  51. keywords: ['@bot', '@助手', '帮我', '请问', '客服'],
  52. defaultReply: '收到,有需要我协助的请详细说明~',
  53. },
  54. replyCooldownSecPerWxid: 5,
  55. };
  56. // ------------- Logging -------------
  57. function ts() { return new Date().toISOString(); }
  58. function log(level, msg, extra) {
  59. const line = `[${ts()}] [${level}] ${msg}` + (extra ? ' ' + JSON.stringify(extra) : '');
  60. console.log(line);
  61. }
  62. const info = (m, e) => log('INFO', m, e);
  63. const warn = (m, e) => log('WARN', m, e);
  64. const error = (m, e) => log('ERROR', m, e);
  65. // ------------- JSON helpers -------------
  66. function readJson(file, fallback) {
  67. try { return JSON.parse(fs.readFileSync(file, 'utf8')); }
  68. catch (e) { if (fallback !== undefined) return fallback; throw e; }
  69. }
  70. function writeJson(file, obj) {
  71. fs.mkdirSync(path.dirname(file), { recursive: true });
  72. fs.writeFileSync(file, JSON.stringify(obj, null, 2));
  73. }
  74. // ------------- HTTP -------------
  75. function httpJson(method, url, body) {
  76. return new Promise((resolve, reject) => {
  77. const u = new URL(url);
  78. const lib = u.protocol === 'https:' ? https : http;
  79. const payload = body ? Buffer.from(JSON.stringify(body)) : null;
  80. const req = lib.request({
  81. hostname: u.hostname,
  82. port: u.port || (u.protocol === 'https:' ? 443 : 80),
  83. path: u.pathname + u.search,
  84. method,
  85. headers: Object.assign(
  86. { 'Accept': 'application/json' },
  87. payload ? { 'Content-Type': 'application/json', 'Content-Length': payload.length } : {},
  88. ),
  89. timeout: 15000,
  90. }, (res) => {
  91. const chunks = [];
  92. res.on('data', c => chunks.push(c));
  93. res.on('end', () => {
  94. const text = Buffer.concat(chunks).toString('utf8');
  95. try { resolve({ status: res.statusCode, json: text ? JSON.parse(text) : null, text }); }
  96. catch (e) { resolve({ status: res.statusCode, json: null, text }); }
  97. });
  98. });
  99. req.on('error', reject);
  100. req.on('timeout', () => { req.destroy(new Error('http timeout')); });
  101. if (payload) req.write(payload);
  102. req.end();
  103. });
  104. }
  105. // ------------- Load config -------------
  106. function loadApiBase() {
  107. const creds = readJson(CREDS_FILE);
  108. if (!creds || !creds.wechatApiBase) throw new Error('wechatApiBase missing in ' + CREDS_FILE);
  109. return creds.wechatApiBase.replace(/\/$/, '');
  110. }
  111. function loadConfig() {
  112. const userCfg = readJson(CONFIG_FILE, {});
  113. return Object.assign({}, DEFAULT_CONFIG, userCfg, {
  114. personal: Object.assign({}, DEFAULT_CONFIG.personal, userCfg.personal || {}),
  115. group: Object.assign({}, DEFAULT_CONFIG.group, userCfg.group || {}),
  116. ignoreTypes: (userCfg.ignoreTypes || DEFAULT_CONFIG.ignoreTypes).slice(),
  117. ignoreWxidPrefixes: (userCfg.ignoreWxidPrefixes || DEFAULT_CONFIG.ignoreWxidPrefixes).slice(),
  118. ignoreWxidExact: (userCfg.ignoreWxidExact || DEFAULT_CONFIG.ignoreWxidExact).slice(),
  119. faq: Array.isArray(userCfg.faq) ? userCfg.faq.slice() : DEFAULT_CONFIG.faq.slice(),
  120. });
  121. }
  122. // Match content against faq rules. Returns matched reply string or null.
  123. function matchFaq(content, faqRules) {
  124. if (!Array.isArray(faqRules) || faqRules.length === 0) return null;
  125. const lower = String(content || '').toLowerCase();
  126. for (const rule of faqRules) {
  127. if (!rule || !Array.isArray(rule.keywords) || !rule.reply) continue;
  128. for (const kw of rule.keywords) {
  129. if (!kw) continue;
  130. if (lower.includes(String(kw).toLowerCase())) {
  131. return { reply: rule.reply, matchedKeyword: kw };
  132. }
  133. }
  134. }
  135. return null;
  136. }
  137. function loadState() {
  138. return readJson(STATE_FILE, { lastCheckTime: '', recentReplies: {} });
  139. }
  140. function saveState(s) { writeJson(STATE_FILE, s); }
  141. // ------------- Filters -------------
  142. function shouldIgnoreMsg(msg, cfg) {
  143. if (msg.direction && msg.direction !== 'received') return 'not-received';
  144. if (cfg.ignoreTypes.includes(msg.type)) return 'ignored-type:' + msg.type;
  145. if (cfg.ignoreWxidExact.includes(msg.fromWxid)) return 'ignored-wxid:' + msg.fromWxid;
  146. for (const p of cfg.ignoreWxidPrefixes) {
  147. if (msg.fromWxid && msg.fromWxid.startsWith(p)) return 'ignored-prefix:' + p;
  148. }
  149. if (!msg.content) return 'empty-content';
  150. return null;
  151. }
  152. function isGroupChat(fromWxid) { return typeof fromWxid === 'string' && fromWxid.endsWith('@chatroom'); }
  153. // Returns { reply, source } or null. source is 'faq' | 'default'.
  154. function pickReply(msg, cfg) {
  155. const content = msg.content || '';
  156. if (isGroupChat(msg.fromWxid)) {
  157. if (!cfg.group.enabled) return null;
  158. const lower = content.toLowerCase();
  159. const triggered = cfg.group.keywords.some(k => lower.includes(String(k).toLowerCase()));
  160. if (!triggered) return null;
  161. const faqHit = matchFaq(content, cfg.faq);
  162. if (faqHit) return { reply: faqHit.reply, source: 'faq:' + faqHit.matchedKeyword };
  163. return { reply: cfg.group.defaultReply, source: 'group-default' };
  164. }
  165. if (!cfg.personal.enabled) return null;
  166. const faqHit = matchFaq(content, cfg.faq);
  167. if (faqHit) return { reply: faqHit.reply, source: 'faq:' + faqHit.matchedKeyword };
  168. return { reply: cfg.personal.defaultReply, source: 'personal-default' };
  169. }
  170. function inCooldown(state, wxid, cooldownSec) {
  171. const lastTs = state.recentReplies[wxid];
  172. if (!lastTs) return false;
  173. return (Date.now() - new Date(lastTs).getTime()) / 1000 < cooldownSec;
  174. }
  175. // ------------- Core loop -------------
  176. let pollCount = 0;
  177. let running = true;
  178. async function pollOnce(apiBase, cfg, state) {
  179. pollCount++;
  180. if (pollCount === 1 || pollCount % cfg.onlineCheckEveryNPolls === 0) {
  181. try {
  182. const r = await httpJson('POST', apiBase + '/login/check-online', {});
  183. if (!r.json || r.json.data !== true) {
  184. warn('wechat offline, skipping this poll', { status: r.status, body: r.text && r.text.slice(0, 200) });
  185. return;
  186. }
  187. } catch (e) {
  188. warn('check-online failed, skipping', { error: e.message });
  189. return;
  190. }
  191. }
  192. const since = state.lastCheckTime || '';
  193. const qs = new URLSearchParams({
  194. limit: String(cfg.maxMessagesPerPoll),
  195. direction: 'received',
  196. });
  197. if (since) qs.set('since', since);
  198. let resp;
  199. try {
  200. resp = await httpJson('GET', apiBase + '/messages?' + qs.toString());
  201. } catch (e) {
  202. error('get-messages failed', { error: e.message });
  203. return;
  204. }
  205. if (!resp.json || !Array.isArray(resp.json.data)) {
  206. warn('get-messages unexpected response', { status: resp.status, body: resp.text && resp.text.slice(0, 300) });
  207. return;
  208. }
  209. const messages = resp.json.data.slice().sort((a, b) => String(a.timestamp).localeCompare(String(b.timestamp)));
  210. if (messages.length === 0) return;
  211. info(`received ${messages.length} new message(s)`);
  212. let maxTs = state.lastCheckTime;
  213. for (const msg of messages) {
  214. if (msg.timestamp && String(msg.timestamp) > String(maxTs || '')) maxTs = msg.timestamp;
  215. const skipReason = shouldIgnoreMsg(msg, cfg);
  216. if (skipReason) { info('skip', { from: msg.fromWxid, type: msg.type, reason: skipReason }); continue; }
  217. if (inCooldown(state, msg.fromWxid, cfg.replyCooldownSecPerWxid)) {
  218. info('cooldown', { from: msg.fromWxid }); continue;
  219. }
  220. const pick = pickReply(msg, cfg);
  221. if (!pick) { info('no-reply-rule', { from: msg.fromWxid, group: isGroupChat(msg.fromWxid) }); continue; }
  222. try {
  223. const send = await httpJson('POST', apiBase + '/message/send-text', {
  224. toWxid: msg.fromWxid,
  225. content: pick.reply,
  226. ats: '',
  227. });
  228. if (send.json && send.json.ret === 200) {
  229. info('replied', { to: msg.fromWxid, nick: msg.nickName, source: pick.source, content: pick.reply });
  230. state.recentReplies[msg.fromWxid] = new Date().toISOString();
  231. } else {
  232. warn('send failed', { to: msg.fromWxid, status: send.status, body: send.text && send.text.slice(0, 200) });
  233. }
  234. } catch (e) {
  235. error('send exception', { to: msg.fromWxid, error: e.message });
  236. }
  237. }
  238. state.lastCheckTime = maxTs || new Date().toISOString();
  239. // Prune old recentReplies (> 1 day) to keep file small
  240. const cutoff = Date.now() - 86400_000;
  241. for (const k of Object.keys(state.recentReplies)) {
  242. if (new Date(state.recentReplies[k]).getTime() < cutoff) delete state.recentReplies[k];
  243. }
  244. saveState(state);
  245. }
  246. async function mainLoop() {
  247. const apiBase = loadApiBase();
  248. info('daemon start', { apiBase, pid: process.pid, nodeVersion: process.version });
  249. fs.mkdirSync(LOG_DIR, { recursive: true });
  250. fs.writeFileSync(PID_FILE, String(process.pid));
  251. const cfg = loadConfig();
  252. info('config loaded', {
  253. pollIntervalMs: cfg.pollIntervalMs,
  254. cooldownSec: cfg.replyCooldownSecPerWxid,
  255. faqRules: cfg.faq.length,
  256. personal: cfg.personal.enabled,
  257. group: cfg.group.enabled + ' keywords=' + cfg.group.keywords.length,
  258. });
  259. let state = loadState();
  260. info('state loaded', { lastCheckTime: state.lastCheckTime || '(empty)' });
  261. while (running) {
  262. const liveCfg = loadConfig(); // hot-reload each loop
  263. try { await pollOnce(apiBase, liveCfg, state); }
  264. catch (e) { error('poll exception', { error: e.message, stack: e.stack }); }
  265. await new Promise(r => setTimeout(r, liveCfg.pollIntervalMs));
  266. }
  267. info('daemon stopping');
  268. try { fs.unlinkSync(PID_FILE); } catch (e) {}
  269. process.exit(0);
  270. }
  271. // ------------- Signal handlers -------------
  272. function shutdown(sig) { info(`signal received: ${sig}`); running = false; }
  273. process.on('SIGTERM', () => shutdown('SIGTERM'));
  274. process.on('SIGINT', () => shutdown('SIGINT'));
  275. process.on('uncaughtException', e => { error('uncaughtException', { error: e.message, stack: e.stack }); });
  276. process.on('unhandledRejection', e => { error('unhandledRejection', { error: String(e) }); });
  277. mainLoop().catch(e => {
  278. error('fatal', { error: e.message, stack: e.stack });
  279. try { fs.unlinkSync(PID_FILE); } catch {}
  280. process.exit(1);
  281. });