Files
clawd/drivers/sim/sms-reader.js
2026-04-30 23:18:26 +08:00

323 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
/**
* SIM 卡短信读取器
* - 扫描 /dev/serial/by-path 中以 :1.3-port0 结尾的 Quectel AT 串口
* - 每 15 秒读取一次,过滤 5 分钟内的短信
* - 去重后追加写入 ~/sms-messages.txt
*/
const fs = require('fs');
const path = require('path');
const os = require('os');
const crypto = require('crypto');
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 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' }
);
}
/** 读取串口原始数据,直到 OK/ERROR 或超时 */
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;
}
/** 发送 AT 命令,返回清理后的响应行数组 */
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);
}
// ── 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 (!/^[0-9A-Fa-f]+$/.test(s)) return '';
try {
const decoded = Buffer.from(s, 'hex').toString('utf16le');
// utf16le 是 little-endianQuectel 发的是 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 ''; }
}
/** 从文本提取 4-8 位验证码 */
function extractOtp(text) {
const m = (text || '').match(/(?<!\d)(\d{4,8})(?!\d)/);
return m ? m[1] : '';
}
/** 解析 +CMGL 响应行为消息对象数组 */
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: '' };
messages.push(current);
} else if (current && !line.startsWith('+')) {
const raw = (current.content + ' ' + line).trim();
current.content = raw;
current.decoded = maybeDecodeUcs2(raw);
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 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}`;
}
/** 过滤 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);
}
filtered.push({ ...item, messages: msgs });
}
return { filtered, newSeen: newSeen.slice(-SEEN_LIMIT) };
}
// ── 读取一个端口 ──────────────────────────────────────────────────────────────
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));
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} ===`];
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(`Time: ${msg.timestamp.toLocaleString('zh-CN', { hour12: false })}`);
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}`);
lines.push('');
}
}
if (!anySms) lines.push(`No new SMS in last ${WINDOW_MINUTES} minutes.`);
return lines.join('\n').trimEnd() + '\n';
}
// ── 主循环 ────────────────────────────────────────────────────────────────────
async function poll() {
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);
}
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);
}
}
main();