Files
clawd/drivers/sim/sms-reader.js
stswangzhiping 46153b5a5e read sms loop
2026-05-01 09:29:04 +08:00

333 lines
9.2 KiB
JavaScript

'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(/(?<!\d)(\d{4,8})(?!\d)/);
return m ? m[1] : '';
}
function parseSmsTimestamp(raw) {
if (!raw) return null;
const m = raw.match(/^(\d{2})\/(\d{2})\/(\d{2}),(\d{2}):(\d{2}):(\d{2})([+-]\d{2})$/);
if (!m) return null;
const [, yy, mo, dd, hh, mi, ss, tzq] = m;
const year = 2000 + Number(yy);
const offsetMinutes = Number(tzq) * 15;
const sign = offsetMinutes >= 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;
});
}