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