diff --git a/bin/clawd.js b/bin/clawd.js index 0798ae9..5c49f7b 100755 --- a/bin/clawd.js +++ b/bin/clawd.js @@ -8,6 +8,7 @@ const path = require('path'); const { exec } = require('child_process'); const { ClawClient } = require('../lib/client'); const log = require('../lib/logger'); +const { pollSms } = require('../drivers/sim/sms-reader'); // 每次启动绑定 Quectel 串口驱动(失败不影响主流程) const bindScript = path.join(__dirname, '..', 'tools', 'bind-quectel-serial.sh'); @@ -16,15 +17,40 @@ exec(`bash "${bindScript}"`, (err, stdout, stderr) => { else log.info('clawd', `bind-quectel-serial: ok`); }); +let smsTimer = null; +let smsPolling = false; + +async function pollSmsSafe() { + if (smsPolling) return; + smsPolling = true; + try { + const text = await pollSms(); + process.stdout.write(text); + } catch (err) { + log.warn('clawd', `sms-reader poll failed: ${err.message}`); + } finally { + smsPolling = false; + } +} + const client = new ClawClient(); client.start(); +pollSmsSafe(); +smsTimer = setInterval(pollSmsSafe, 15_000); + let stopping = false; function shutdown(signal) { if (stopping) return; stopping = true; log.info('clawd', `收到 ${signal},正在停止...`); + + if (smsTimer) { + clearInterval(smsTimer); + smsTimer = null; + } + client.stop(); setTimeout(() => process.exit(0), 500); } diff --git a/drivers/sim/sms-reader.js b/drivers/sim/sms-reader.js index 70b7eb5..a38b0bf 100644 --- a/drivers/sim/sms-reader.js +++ b/drivers/sim/sms-reader.js @@ -3,31 +3,25 @@ /** * SIM 卡短信读取器 * - 扫描 /dev/serial/by-path 中以 :1.3-port0 结尾的 Quectel AT 串口 - * - 每 15 秒读取一次,过滤 5 分钟内的短信 - * - 去重后追加写入 ~/sms-messages.txt + * - 单次执行:读取并覆盖写入 /home/sts/sms-messages.txt + * - 不去重 + * - 显示短信真实时间(取自 +CMGL 头) + * - 只保留最近 15 分钟内的短信 + * - 多卡安全长短信拼接:按 IMSI + From + timestampRaw 合并相邻分片 */ -const fs = require('fs'); -const path = require('path'); -const os = require('os'); -const crypto = require('crypto'); +const fs = require('fs'); +const path = require('path'); const { execSync } = require('child_process'); -const BY_PATH_DIR = '/dev/serial/by-path'; -const TARGET_SUFFIX = ':1.3-port0'; -const OUT_FILE = path.join(os.homedir(), 'sms-messages.txt'); -const STATE_FILE = path.join(os.homedir(), '.sms-seen.json'); -const BAUD = 9600; -const POLL_MS = 15_000; -const WINDOW_MINUTES = 5; -const SEEN_LIMIT = 2000; - -// ── 工具 ───────────────────────────────────────────────────────────────────── +const BY_PATH_DIR = '/dev/serial/by-path'; +const TARGET_SUFFIX = ':1.3-port0'; +const OUT_FILE = '/home/sts/sms-messages.txt'; +const BAUD = 9600; +const WINDOW_MINUTES = 15; const sleep = ms => new Promise(r => setTimeout(r, ms)); -// ── 串口操作 ────────────────────────────────────────────────────────────────── - function setupPort(dev) { execSync( `stty -F "${dev}" ${BAUD} cs8 -cstopb -parenb cread clocal -crtscts raw ` + @@ -36,11 +30,10 @@ function setupPort(dev) { ); } -/** 读取串口原始数据,直到 OK/ERROR 或超时 */ async function readRaw(fd, timeoutMs = 2000, stopOnOk = true) { - const buf = Buffer.alloc(4096); + const buf = Buffer.alloc(4096); const deadline = Date.now() + timeoutMs; - let text = ''; + let text = ''; while (Date.now() < deadline) { let n = 0; @@ -53,7 +46,6 @@ async function readRaw(fd, timeoutMs = 2000, stopOnOk = true) { if (n > 0) { text += buf.slice(0, n).toString('utf8'); if (stopOnOk && (/\nOK|\rOK|ERROR/.test(text))) { - // 排尽剩余缓冲 await sleep(120); try { while (true) { @@ -71,10 +63,10 @@ async function readRaw(fd, timeoutMs = 2000, stopOnOk = true) { return text; } -/** 发送 AT 命令,返回清理后的响应行数组 */ async function atCmd(fd, command, waitMs = 2000) { - // 清空输入缓冲 - try { while (fs.readSync(fd, Buffer.alloc(256), 0, 256, null) > 0) {} } catch (_) {} + try { + while (fs.readSync(fd, Buffer.alloc(256), 0, 256, null) > 0) {} + } catch (_) {} fs.writeSync(fd, Buffer.from(command + '\r')); const raw = await readRaw(fd, waitMs, true); return cleanLines(raw); @@ -94,8 +86,6 @@ function cleanLines(raw) { }); } -// ── 端口发现 ────────────────────────────────────────────────────────────────── - function discoverPorts() { if (!fs.existsSync(BY_PATH_DIR)) return []; return fs.readdirSync(BY_PATH_DIR) @@ -106,217 +96,237 @@ function discoverPorts() { try { const real = fs.realpathSync(link); return fs.existsSync(real) ? { link, real } : null; - } catch (_) { return null; } + } catch (_) { + return null; + } }) .filter(Boolean); } -// ── SMS 解析 ────────────────────────────────────────────────────────────────── - -/** 尝试将纯 UCS2 hex 解码为中文 */ function maybeDecodeUcs2(text) { const s = (text || '').replace(/\s+/g, ''); - if (!s || s.length % 4 !== 0) return ''; - if (/^\d+$/.test(s)) return ''; // 纯数字不解码(验证码原文) + if (!s) return ''; + if (/^\d+$/.test(s)) return ''; + if (s.length % 4 !== 0) return ''; if (!/^[0-9A-Fa-f]+$/.test(s)) return ''; try { - const decoded = Buffer.from(s, 'hex').toString('utf16le'); - // utf16le 是 little-endian,Quectel 发的是 big-endian,需交换字节 - const beDecoded = Buffer.from(s, 'hex').swap16().toString('utf16le'); - const printable = c => c >= ' '; - const score = s => [...s].filter(printable).length / Math.max(1, s.length); - const best = score(beDecoded) >= score(decoded) ? beDecoded : decoded; - return score(best) >= 0.85 ? best.trim() : ''; - } catch (_) { return ''; } + return Buffer.from(s, 'hex').swap16().toString('utf16le').trim(); + } catch (_) { + return ''; + } } -/** 从文本提取 4-8 位验证码 */ function extractOtp(text) { const m = (text || '').match(/(?= 0 ? '+' : '-'; + const abs = Math.abs(offsetMinutes); + const offH = String(Math.floor(abs / 60)).padStart(2, '0'); + const offM = String(abs % 60).padStart(2, '0'); + + const iso = `${year}-${mo}-${dd}T${hh}:${mi}:${ss}${sign}${offH}:${offM}`; + const d = new Date(iso); + return Number.isNaN(d.getTime()) ? null : d; +} + +function formatDate(d) { + if (!d || Number.isNaN(d.getTime())) return 'UNKNOWN'; + const pad = n => String(n).padStart(2, '0'); + const y = d.getFullYear(); + const mo = pad(d.getMonth() + 1); + const da = pad(d.getDate()); + const hh = pad(d.getHours()); + const mi = pad(d.getMinutes()); + const ss = pad(d.getSeconds()); + + const offset = -d.getTimezoneOffset(); + const sign = offset >= 0 ? '+' : '-'; + const abs = Math.abs(offset); + const offH = pad(Math.floor(abs / 60)); + const offM = pad(abs % 60); + + return `${y}-${mo}-${da} ${hh}:${mi}:${ss} ${sign}${offH}${offM}`; +} + +function recentOnly(messages, minutes = WINDOW_MINUTES) { + const cutoff = Date.now() - minutes * 60 * 1000; + return messages.filter(msg => { + if (!msg.timestamp || Number.isNaN(msg.timestamp.getTime())) return false; + return msg.timestamp.getTime() >= cutoff; + }); +} + function parseSms(lines) { const messages = []; let current = null; + for (const line of lines) { - if (line.startsWith('+CMGL:') || line.startsWith('+CMGR:')) { - const parts = line.split(',').map(p => p.trim().replace(/^"|"$/g, '')); - const sender = line.startsWith('+CMGL:') - ? (parts[2] || 'UNKNOWN') - : (parts[1] || 'UNKNOWN'); - // 时间戳在 +CMGL: idx,stat,sender,,"yy/MM/dd,HH:mm:ss+tz" - const tsRaw = parts[4] || ''; - current = { sender, tsRaw, timestamp: parseTimestamp(tsRaw), content: '', decoded: '' }; + if (line.startsWith('+CMGL:')) { + const m = line.match(/^\+CMGL:\s*\d+,"[^"]*","([^"]*)",,"(\d{2}\/\d{2}\/\d{2},\d{2}:\d{2}:\d{2}[+-]\d{2})"$/); + const sender = m ? (m[1] || 'UNKNOWN') : 'UNKNOWN'; + const timestampRaw = m ? m[2] : ''; + current = { + sender, + timestampRaw, + timestamp: parseSmsTimestamp(timestampRaw), + content: '', + decoded: '', + code: '', + }; messages.push(current); - } else if (current && !line.startsWith('+')) { + continue; + } + + if (line.startsWith('+CMGR:')) { + const parts = line.split(',').map(p => p.trim().replace(/^\"|\"$/g, '')); + const sender = parts[1] || 'UNKNOWN'; + current = { + sender, + timestampRaw: '', + timestamp: null, + content: '', + decoded: '', + code: '', + }; + messages.push(current); + continue; + } + + if (current && !line.startsWith('+')) { const raw = (current.content + ' ' + line).trim(); current.content = raw; current.decoded = maybeDecodeUcs2(raw); + current.code = extractOtp(current.decoded || current.content); current = null; } } + return messages; } -/** 解析 Quectel 时间戳 "26/04/30,12:34:56+32"(+tz 单位 1/4 小时) */ -function parseTimestamp(raw) { - if (!raw) return new Date(); - const m = raw.match(/(\d{2})\/(\d{2})\/(\d{2}),(\d{2}):(\d{2}):(\d{2})([+-]\d+)?/); - if (!m) return new Date(); - const [, yy, mo, dd, hh, mm, ss, tz] = m; - const tzMin = tz ? parseInt(tz) * 15 : 0; - const iso = `20${yy}-${mo}-${dd}T${hh}:${mm}:${ss}${tzOffsetStr(tzMin)}`; - const d = new Date(iso); - return isNaN(d) ? new Date() : d; -} +function mergeLongSms(imsi, messages) { + const merged = []; -function tzOffsetStr(totalMin) { - const sign = totalMin >= 0 ? '+' : '-'; - const abs = Math.abs(totalMin); - const h = String(Math.floor(abs / 60)).padStart(2, '0'); - const m = String(abs % 60).padStart(2, '0'); - return `${sign}${h}:${m}`; -} + for (const msg of messages) { + const prev = merged[merged.length - 1]; + const sameGroup = + prev && + (prev._mergeImsi === imsi) && + (prev.sender === msg.sender) && + (prev.timestampRaw === msg.timestampRaw); -/** 过滤 N 分钟内的短信 */ -function recentOnly(messages, minutes = WINDOW_MINUTES) { - const cutoff = Date.now() - minutes * 60 * 1000; - return messages.filter(m => m.timestamp >= cutoff); -} - -// ── 去重 ────────────────────────────────────────────────────────────────────── - -function fingerprint(imsi, msg) { - const key = [ - imsi || 'UNKNOWN', - msg.sender || 'UNKNOWN', - msg.decoded || msg.content || '', - msg.timestamp.toISOString(), - ].join('|'); - return crypto.createHash('sha1').update(key).digest('hex'); -} - -function loadSeen() { - try { - const data = JSON.parse(fs.readFileSync(STATE_FILE, 'utf8')); - return Array.isArray(data) ? data : []; - } catch (_) { return []; } -} - -function saveSeen(seen) { - fs.writeFileSync(STATE_FILE, JSON.stringify(seen.slice(-SEEN_LIMIT), null, 2), 'utf8'); -} - -function filterNew(results, seen) { - const seenSet = new Set(seen); - const newSeen = [...seen]; - const filtered = []; - for (const item of results) { - const msgs = []; - for (const msg of item.messages) { - const fp = fingerprint(item.imsi, msg); - if (seenSet.has(fp)) continue; - seenSet.add(fp); - newSeen.push(fp); - msgs.push(msg); + if (!sameGroup) { + merged.push({ + ...msg, + _mergeImsi: imsi, + }); + continue; } - filtered.push({ ...item, messages: msgs }); - } - return { filtered, newSeen: newSeen.slice(-SEEN_LIMIT) }; -} -// ── 读取一个端口 ────────────────────────────────────────────────────────────── + prev.content = [prev.content, msg.content].filter(Boolean).join(''); + const prevText = prev.decoded || prev.content || ''; + const nextText = msg.decoded || msg.content || ''; + const joinedText = [prevText, nextText].filter(Boolean).join(''); + + // 合并后优先重新按整体判断是否可解码;否则用拼接后的文本 + const redecoded = maybeDecodeUcs2((prev.content || '')); + prev.decoded = redecoded || joinedText; + prev.code = extractOtp(prev.decoded || prev.content); + } + + return merged.map(({ _mergeImsi, ...rest }) => rest); +} async function readPort({ link, real }) { let fd; try { setupPort(real); fd = fs.openSync(real, fs.constants.O_RDWR | fs.constants.O_NOCTTY | fs.constants.O_NONBLOCK); - - // 排空缓冲 await readRaw(fd, 400, false); - // 获取 IMSI const cimiLines = await atCmd(fd, 'AT+CIMI'); const imsiMatch = cimiLines.join('\n').match(/\b(\d{15})\b/); const imsi = imsiMatch ? imsiMatch[1] : 'UNKNOWN'; - // 切换文本模式 await atCmd(fd, 'AT+CMGF=1'); - - // 读取所有短信 const smsLines = await atCmd(fd, 'AT+CMGL="ALL"', 4000); - const messages = recentOnly(parseSms(smsLines)); + + const parsed = parseSms(smsLines); + const merged = mergeLongSms(imsi, parsed); + const messages = recentOnly(merged, WINDOW_MINUTES); return { link, device: real, imsi, messages }; } catch (e) { return { link, device: real, imsi: 'UNKNOWN', messages: [], error: e.message }; } finally { - if (fd !== undefined) try { fs.closeSync(fd); } catch (_) {} + if (fd !== undefined) { + try { fs.closeSync(fd); } catch (_) {} + } } } -// ── 输出渲染 ────────────────────────────────────────────────────────────────── - function render(results) { - const now = new Date().toLocaleString('zh-CN', { hour12: false, + const now = new Date().toLocaleString('zh-CN', { + hour12: false, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', - timeZoneName: 'short' }); + timeZoneName: 'short', + }); + const lines = [`=== ${now} ===`]; + + if (!results.length) { + lines.push('No matching ports found.'); + return lines.join('\n') + '\n'; + } + let anySms = false; for (const item of results) { - const errStr = item.error ? ` ERROR: ${item.error}` : ''; - lines.push(`Port: ${item.device} IMSI: ${item.imsi} Messages: ${item.messages.length}${errStr}`); + const errStr = item.error ? ` ERROR: ${item.error}` : ''; + lines.push(`Port: ${item.device} IMSI: ${item.imsi} Messages: ${item.messages.length}${errStr}`); + for (const msg of item.messages) { anySms = true; - lines.push(`Time: ${msg.timestamp.toLocaleString('zh-CN', { hour12: false })}`); - lines.push(`IMSI: ${item.imsi}`); - lines.push(`From: ${msg.sender}`); + lines.push(`SmsTime: ${formatDate(msg.timestamp)}`); + lines.push(`IMSI: ${item.imsi}`); + lines.push(`From: ${msg.sender}`); lines.push(`Content: ${msg.decoded || msg.content}`); - const otp = extractOtp(msg.decoded || msg.content); - if (otp) lines.push(`Code: ${otp}`); + if (msg.code) lines.push(`Code: ${msg.code}`); lines.push(''); } } - if (!anySms) lines.push(`No new SMS in last ${WINDOW_MINUTES} minutes.`); + + if (!anySms) lines.push(`No SMS found in last ${WINDOW_MINUTES} minutes.`); return lines.join('\n').trimEnd() + '\n'; } -// ── 主循环 ──────────────────────────────────────────────────────────────────── - -async function poll() { - const ports = discoverPorts(); +async function pollSms() { + const ports = discoverPorts(); const results = await Promise.all(ports.map(readPort)); - - for (const item of results) { - for (const msg of item.messages) { - msg.otp = extractOtp(msg.decoded || msg.content); - } - } - - const seen = loadSeen(); - const { filtered, newSeen } = filterNew(results, seen); - const text = render(filtered); - - fs.appendFileSync(OUT_FILE, text, 'utf8'); - saveSeen(newSeen); - process.stdout.write(text); + const text = render(results); + fs.writeFileSync(OUT_FILE, text, 'utf8'); + return text; } -async function main() { - console.log(`SIM SMS reader started. Poll every ${POLL_MS / 1000}s → ${OUT_FILE}`); - while (true) { - try { - await poll(); - } catch (e) { - console.error('poll error:', e.message); - } - await sleep(POLL_MS); - } -} +module.exports = { pollSms }; -main(); +if (require.main === module) { + pollSms() + .then(text => process.stdout.write(text)) + .catch(err => { + console.error(`sms-reader error: ${err.message}`); + process.exitCode = 1; + }); +}