'use strict'; /** * SIM 卡短信读取器 * - 扫描 /dev/serial/by-path 中以 :1.3-port0 结尾的 Quectel AT 串口 * - 单次执行:读取并覆盖写入 /home/sts/sms-messages.txt * - 不去重 * - 显示短信真实时间(取自 +CMGL 头) * - 只保留最近 15 分钟内的短信 * - 多卡安全长短信拼接:按 IMSI + From + timestampRaw 合并相邻分片 */ 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 = '/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 ` + `-echo -echoe -echok -echonl min 0 time 5`, { stdio: 'ignore' } ); } async function readRaw(fd, timeoutMs = 2000, stopOnOk = true) { const buf = Buffer.alloc(4096); const deadline = Date.now() + timeoutMs; let text = ''; while (Date.now() < deadline) { let n = 0; try { n = fs.readSync(fd, buf, 0, buf.length, null); } catch (e) { if (e.code !== 'EAGAIN' && e.code !== 'EWOULDBLOCK') throw e; } if (n > 0) { text += buf.slice(0, n).toString('utf8'); if (stopOnOk && (/\nOK|\rOK|ERROR/.test(text))) { await sleep(120); try { while (true) { const n2 = fs.readSync(fd, buf, 0, buf.length, null); if (n2 <= 0) break; text += buf.slice(0, n2).toString('utf8'); } } catch (_) {} break; } } else { await sleep(50); } } return text; } async function atCmd(fd, command, waitMs = 2000) { 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); } function cleanLines(raw) { return raw .replace(/\r/g, '\n') .split('\n') .map(l => l.trim()) .filter(l => { if (!l) return false; if (l === 'OK' || l === 'ERROR') return false; if (l.startsWith('AT')) return false; const printable = [...l].filter(c => c >= ' ').length; return printable / Math.max(1, l.length) >= 0.85; }); } function discoverPorts() { if (!fs.existsSync(BY_PATH_DIR)) return []; return fs.readdirSync(BY_PATH_DIR) .filter(n => n.endsWith(TARGET_SUFFIX)) .sort() .map(n => { const link = path.join(BY_PATH_DIR, n); try { const real = fs.realpathSync(link); return fs.existsSync(real) ? { link, real } : null; } catch (_) { return null; } }) .filter(Boolean); } function maybeDecodeUcs2(text) { const s = (text || '').replace(/\s+/g, ''); if (!s) return ''; if (/^\d+$/.test(s)) return ''; if (s.length % 4 !== 0) return ''; if (!/^[0-9A-Fa-f]+$/.test(s)) return ''; try { return Buffer.from(s, 'hex').swap16().toString('utf16le').trim(); } catch (_) { return ''; } } 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:')) { 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); 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; } function mergeLongSms(imsi, messages) { const merged = []; 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); if (!sameGroup) { merged.push({ ...msg, _mergeImsi: imsi, }); continue; } 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); 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 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 (_) {} } } } function render(results) { 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', }); 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}`); for (const msg of item.messages) { anySms = true; lines.push(`SmsTime: ${formatDate(msg.timestamp)}`); lines.push(`IMSI: ${item.imsi}`); lines.push(`From: ${msg.sender}`); lines.push(`Content: ${msg.decoded || msg.content}`); if (msg.code) lines.push(`Code: ${msg.code}`); lines.push(''); } } if (!anySms) lines.push(`No SMS found in last ${WINDOW_MINUTES} minutes.`); return lines.join('\n').trimEnd() + '\n'; } async function pollSms() { const ports = discoverPorts(); const results = await Promise.all(ports.map(readPort)); const text = render(results); fs.writeFileSync(OUT_FILE, text, 'utf8'); return text; } module.exports = { pollSms }; if (require.main === module) { pollSms() .then(text => process.stdout.write(text)) .catch(err => { console.error(`sms-reader error: ${err.message}`); process.exitCode = 1; }); }