586 lines
17 KiB
JavaScript
586 lines
17 KiB
JavaScript
import { parse } from 'node-pdu';
|
|
import crypto from 'crypto';
|
|
import fs from 'fs';
|
|
import path from 'path';
|
|
import { fileURLToPath } from 'url';
|
|
import logger from './logger.js';
|
|
|
|
const __filename = fileURLToPath(import.meta.url);
|
|
const __dirname = path.dirname(__filename);
|
|
|
|
class SMSProcessor {
|
|
constructor(config, modem, concatManager, pushManager) {
|
|
this.config = config;
|
|
this.modem = modem;
|
|
this.concatManager = concatManager;
|
|
this.pushManager = pushManager;
|
|
this.receivedMessages = [];
|
|
this.receivedMessageSeq = 0;
|
|
this.maxReceivedMessages = this.resolveMaxReceivedMessages();
|
|
this.receivedMessagesFile = this.resolveReceivedMessagesFile();
|
|
this.partitionMessagesBySim = this.resolvePartitionMessagesBySim();
|
|
this.allowAllSimMessages = this.resolveAllowAllSimMessages();
|
|
this.currentSimIdentity = null;
|
|
this.currentSimCheckedAt = 0;
|
|
this.currentSimLookup = null;
|
|
this.simIdentityTtlMs = 5 * 1000;
|
|
this.loadReceivedMessages();
|
|
}
|
|
|
|
normalizeIncomingPduEvent(input) {
|
|
if (typeof input === 'string') {
|
|
return {
|
|
pdu: input,
|
|
metadata: {
|
|
type: 'CMT',
|
|
raw: '',
|
|
simSlot: null,
|
|
receivedAt: new Date().toISOString()
|
|
}
|
|
};
|
|
}
|
|
|
|
if (input && typeof input === 'object') {
|
|
return {
|
|
pdu: String(input.pdu || input.pduHex || ''),
|
|
metadata: {
|
|
...(input.metadata || {}),
|
|
simSlot: this.normalizeSimSlot(input.metadata?.simSlot ?? input.simSlot),
|
|
receivedAt: input.metadata?.receivedAt || input.receivedAt || new Date().toISOString()
|
|
}
|
|
};
|
|
}
|
|
|
|
return {
|
|
pdu: '',
|
|
metadata: {
|
|
type: 'CMT',
|
|
raw: '',
|
|
simSlot: null,
|
|
receivedAt: new Date().toISOString()
|
|
}
|
|
};
|
|
}
|
|
|
|
resolveIncomingSimIdentity({ metadata = {} } = {}) {
|
|
const urcSlot = this.normalizeSimSlot(metadata.simSlot);
|
|
if (urcSlot !== null) {
|
|
return this.createSimIdentityFromSlot(urcSlot, {
|
|
simSource: metadata.simSource || 'urc'
|
|
});
|
|
}
|
|
|
|
const identified = this.modem?.identifyIncomingSms?.(metadata);
|
|
if (identified?.slot !== undefined && identified?.slot !== null) {
|
|
return this.createSimIdentityFromSlot(identified.slot, {
|
|
simSource: identified.source || 'slot'
|
|
});
|
|
}
|
|
|
|
const directSource = metadata.simSource || (metadata.type === 'CMT' || metadata.type === 'CMTI' ? 'urc' : '');
|
|
if (directSource) {
|
|
return {
|
|
simId: crypto.createHash('sha256').update(`unknown-${directSource}`).digest('hex').slice(0, 24),
|
|
simLabel: '未知SIM',
|
|
simSlot: null,
|
|
simSource: directSource,
|
|
smsCenter: ''
|
|
};
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
createSimIdentityFromSlot(slotNumber, extra = {}) {
|
|
const slot = this.normalizeSimSlot(slotNumber);
|
|
if (slot === null) {
|
|
return null;
|
|
}
|
|
|
|
const simStatus = this.modem?.getCachedSimStatus?.();
|
|
const slotInfo = simStatus?.slots?.find(item => item.slot === slot);
|
|
const name = slotInfo?.phoneLabel || slotInfo?.phoneNumber || slotInfo?.name || `SIM${slot + 1}`;
|
|
|
|
return {
|
|
simId: crypto.createHash('sha256').update(`slot:${slot}`).digest('hex').slice(0, 24),
|
|
simLabel: name,
|
|
simSlot: slot,
|
|
simSource: extra.simSource || 'slot',
|
|
smsCenter: ''
|
|
};
|
|
}
|
|
|
|
normalizeSimSlot(value) {
|
|
if (typeof value === 'string') {
|
|
const normalized = value.trim().toUpperCase();
|
|
if (normalized === 'SIM1') {
|
|
return 0;
|
|
}
|
|
if (normalized === 'SIM2') {
|
|
return 1;
|
|
}
|
|
}
|
|
|
|
const slot = Number.parseInt(value, 10);
|
|
return slot === 0 || slot === 1 ? slot : null;
|
|
}
|
|
|
|
formatSimSource(source) {
|
|
const labels = {
|
|
urc: 'URC上报',
|
|
bind_context: '绑定上下文',
|
|
active_context: '当前卡槽',
|
|
slot: '卡槽',
|
|
current_slot: '当前卡槽'
|
|
};
|
|
|
|
return labels[source] || '未知';
|
|
}
|
|
|
|
/**
|
|
* 处理接收到的PDU短信
|
|
*/
|
|
async processPDU(input) {
|
|
try {
|
|
logger.info('开始解析PDU数据...');
|
|
|
|
const smsEvent = this.normalizeIncomingPduEvent(input);
|
|
const pduHex = smsEvent.pdu;
|
|
|
|
// 解析PDU
|
|
const parsed = parse(pduHex);
|
|
|
|
if (!parsed) {
|
|
logger.error('PDU解析失败');
|
|
return;
|
|
}
|
|
|
|
const sender = parsed.address?.phone || '未知号码';
|
|
const timestamp = parsed.serviceCenterTimeStamp?.getIsoString?.() || new Date().toISOString();
|
|
const text = parsed.data?.getText?.() || '';
|
|
const part = parsed.data?.parts?.[0];
|
|
const header = part?.header;
|
|
const simIdentity = this.resolveIncomingSimIdentity({
|
|
metadata: smsEvent.metadata
|
|
});
|
|
|
|
logger.info('✓ PDU解析成功');
|
|
logger.info(`发送者: ${sender}`);
|
|
logger.info(`时间戳: ${timestamp}`);
|
|
logger.info(`收件SIM: ${simIdentity?.simLabel || '未知SIM'}`);
|
|
logger.info(`识别方式: ${this.formatSimSource(simIdentity?.simSource)}`);
|
|
logger.info(`内容: ${text}`);
|
|
|
|
// 检查是否为长短信
|
|
if (header && header.getType() !== undefined) {
|
|
// 长短信头部
|
|
const refNumber = header.getPointer();
|
|
const totalParts = header.getSegments();
|
|
const partNumber = header.getCurrent();
|
|
|
|
logger.info(`长短信信息: 参考号=${refNumber}, 当前=${partNumber}, 总计=${totalParts}`);
|
|
|
|
// 添加到长短信缓存
|
|
this.concatManager.addPart(
|
|
refNumber,
|
|
sender,
|
|
partNumber,
|
|
totalParts,
|
|
text,
|
|
timestamp,
|
|
{ simIdentity }
|
|
);
|
|
} else {
|
|
// 普通短信,直接处理
|
|
await this.processSmsContent(sender, text, timestamp, { simIdentity });
|
|
}
|
|
} catch (err) {
|
|
logger.error('处理PDU失败:', err);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 处理短信内容并转发
|
|
*/
|
|
async processSmsContent(sender, text, timestamp, options = {}) {
|
|
const simIdentity = options.simIdentity !== undefined
|
|
? this.normalizeSimIdentity(options.simIdentity)
|
|
: await this.getCurrentSimIdentity();
|
|
|
|
logger.info('=== 处理短信内容 ===');
|
|
logger.info(`发送者: ${sender}`);
|
|
logger.info(`时间戳: ${timestamp}`);
|
|
logger.info(`收件SIM: ${simIdentity?.simLabel || '未知SIM'}`);
|
|
logger.info(`识别方式: ${this.formatSimSource(simIdentity?.simSource)}`);
|
|
logger.info(`内容: ${text}`);
|
|
logger.info('====================');
|
|
|
|
const messageRecord = this.addReceivedMessage(sender, text, timestamp, simIdentity);
|
|
|
|
// 推送到所有通道
|
|
await this.pushManager.pushToAll(sender, text, timestamp);
|
|
|
|
// 发送邮件通知
|
|
const subject = `短信${sender},${text.substring(0, 20)}`;
|
|
const body = `来自:${sender},时间:${timestamp},内容:${text}`;
|
|
await this.pushManager.sendEmail(subject, body);
|
|
|
|
this.updateReceivedMessage(messageRecord.id, {
|
|
status: 'forwarded',
|
|
statusText: '已转发'
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 记录收到的短信,供Web管理端展示。
|
|
*/
|
|
addReceivedMessage(sender, text, timestamp, simIdentity = null) {
|
|
const normalizedSimIdentity = this.normalizeSimIdentity(simIdentity);
|
|
const message = {
|
|
id: `${Date.now()}-${++this.receivedMessageSeq}`,
|
|
sender,
|
|
text,
|
|
timestamp,
|
|
receivedAt: new Date().toISOString(),
|
|
simId: normalizedSimIdentity?.simId || '',
|
|
simLabel: normalizedSimIdentity?.simLabel || '未知SIM',
|
|
simSlot: normalizedSimIdentity?.simSlot ?? null,
|
|
simSource: normalizedSimIdentity?.simSource || 'unknown',
|
|
smsCenter: normalizedSimIdentity?.smsCenter || '',
|
|
status: 'received',
|
|
statusText: '已接收'
|
|
};
|
|
|
|
this.receivedMessages.unshift(message);
|
|
if (this.receivedMessages.length > this.maxReceivedMessages) {
|
|
this.receivedMessages.length = this.maxReceivedMessages;
|
|
}
|
|
|
|
this.persistReceivedMessages();
|
|
logger.info(`短信已进入Web收件箱: ${sender} (${message.simLabel})`);
|
|
return message;
|
|
}
|
|
|
|
/**
|
|
* 更新收件箱短信状态。
|
|
*/
|
|
updateReceivedMessage(id, patch) {
|
|
const message = this.receivedMessages.find(item => item.id === id);
|
|
if (message) {
|
|
Object.assign(message, patch);
|
|
this.persistReceivedMessages();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 获取最近收到的短信。
|
|
*/
|
|
getReceivedMessages(limit = 50) {
|
|
return this.receivedMessages.slice(0, this.resolveReceivedMessagesLimit(limit));
|
|
}
|
|
|
|
/**
|
|
* 获取按SIM作用域过滤后的短信。
|
|
*/
|
|
listReceivedMessages(limit = 50, options = {}) {
|
|
const safeLimit = this.resolveReceivedMessagesLimit(limit);
|
|
const requestedSimSlot = this.normalizeSimSlot(options.simSlot);
|
|
const hasRequestedScope = options.scope !== undefined && options.scope !== null && String(options.scope).trim() !== '';
|
|
const requestedScope = requestedSimSlot !== null
|
|
? `sim${requestedSimSlot + 1}`
|
|
: hasRequestedScope
|
|
? String(options.scope).trim().toLowerCase()
|
|
: (this.shouldDefaultToAllSimMessages() ? 'all' : 'current');
|
|
const currentSim = this.normalizeSimIdentity(options.currentSimIdentity);
|
|
const autoAllSims = !hasRequestedScope && requestedScope === 'all';
|
|
const includeAllSims = requestedSimSlot === null && requestedScope === 'all';
|
|
|
|
let effectiveScope = includeAllSims ? 'all' : 'current';
|
|
let messages = this.receivedMessages;
|
|
|
|
if (requestedSimSlot !== null) {
|
|
messages = this.receivedMessages.filter(message => this.normalizeSimSlot(message.simSlot) === requestedSimSlot);
|
|
effectiveScope = `sim${requestedSimSlot + 1}`;
|
|
} else if (this.partitionMessagesBySim && !includeAllSims) {
|
|
if (currentSim?.simId) {
|
|
messages = this.receivedMessages.filter(message => this.messageMatchesSim(message, currentSim));
|
|
} else {
|
|
messages = [];
|
|
effectiveScope = 'unavailable';
|
|
}
|
|
} else if (!this.partitionMessagesBySim) {
|
|
effectiveScope = 'all';
|
|
}
|
|
|
|
return {
|
|
messages: messages.slice(0, safeLimit),
|
|
meta: {
|
|
limit: safeLimit,
|
|
total: messages.length,
|
|
scope: effectiveScope,
|
|
requestedScope,
|
|
partitionBySim: this.partitionMessagesBySim,
|
|
allSimMessagesAllowed: this.allowAllSimMessages || includeAllSims || autoAllSims,
|
|
allSimMessagesDenied: false,
|
|
simSlot: requestedSimSlot,
|
|
currentSim,
|
|
currentSimKnown: Boolean(currentSim?.simId)
|
|
}
|
|
};
|
|
}
|
|
|
|
shouldDefaultToAllSimMessages() {
|
|
if (this.config.inbox?.dualSimDefaultScope === 'current') {
|
|
return false;
|
|
}
|
|
|
|
const simStatus = this.modem?.getCachedSimStatus?.();
|
|
return simStatus?.supported === true && (simStatus.mode === 0 || simStatus.mode === 3);
|
|
}
|
|
|
|
messageMatchesSim(message, currentSim) {
|
|
if (!message || !currentSim) {
|
|
return false;
|
|
}
|
|
|
|
if (message.simId && currentSim.simId && message.simId === currentSim.simId) {
|
|
return true;
|
|
}
|
|
|
|
const messageSlot = this.normalizeSimSlot(message.simSlot);
|
|
const currentSlot = this.normalizeSimSlot(currentSim.simSlot);
|
|
return messageSlot !== null && currentSlot !== null && messageSlot === currentSlot;
|
|
}
|
|
|
|
/**
|
|
* 限制收件箱查询条数。
|
|
*/
|
|
resolveReceivedMessagesLimit(limit = 50) {
|
|
return Math.min(Math.max(Number(limit) || 50, 1), this.maxReceivedMessages);
|
|
}
|
|
|
|
/**
|
|
* 解析收件箱保留条数。
|
|
*/
|
|
resolveMaxReceivedMessages() {
|
|
const configured = Number(this.config.inbox?.maxReceivedMessages);
|
|
if (!Number.isFinite(configured) || configured <= 0) {
|
|
return 200;
|
|
}
|
|
|
|
return Math.min(Math.floor(configured), 5000);
|
|
}
|
|
|
|
/**
|
|
* 是否按当前SIM隔离Web收件箱。默认开启,避免换卡后读取其他SIM历史。
|
|
*/
|
|
resolvePartitionMessagesBySim() {
|
|
return this.config.inbox?.partitionBySim !== false;
|
|
}
|
|
|
|
/**
|
|
* 是否允许显式查询全部SIM历史。默认关闭。
|
|
*/
|
|
resolveAllowAllSimMessages() {
|
|
return this.config.inbox?.allowAllSimMessages === true;
|
|
}
|
|
|
|
/**
|
|
* 解析收件箱持久化文件路径。
|
|
*/
|
|
resolveReceivedMessagesFile() {
|
|
const configuredPath = this.config.inbox?.receivedMessagesFile || 'data/received-messages.json';
|
|
if (path.isAbsolute(configuredPath)) {
|
|
return configuredPath;
|
|
}
|
|
|
|
return path.join(__dirname, '..', configuredPath);
|
|
}
|
|
|
|
/**
|
|
* 启动时加载持久化收件箱。
|
|
*/
|
|
loadReceivedMessages() {
|
|
try {
|
|
fs.mkdirSync(path.dirname(this.receivedMessagesFile), { recursive: true });
|
|
|
|
if (!fs.existsSync(this.receivedMessagesFile)) {
|
|
logger.info(`短信收件箱持久化文件不存在,将在收到短信后创建: ${this.receivedMessagesFile}`);
|
|
return;
|
|
}
|
|
|
|
const content = fs.readFileSync(this.receivedMessagesFile, 'utf8').trim();
|
|
if (!content) {
|
|
return;
|
|
}
|
|
|
|
const parsed = JSON.parse(content);
|
|
if (!Array.isArray(parsed)) {
|
|
logger.warn('短信收件箱持久化文件格式不是数组,已忽略');
|
|
return;
|
|
}
|
|
|
|
this.receivedMessages = parsed
|
|
.map(item => this.normalizeStoredMessage(item))
|
|
.filter(Boolean)
|
|
.slice(0, this.maxReceivedMessages);
|
|
|
|
logger.info(`已加载持久化短信收件箱: ${this.receivedMessages.length} 条`);
|
|
} catch (err) {
|
|
logger.error('加载短信收件箱持久化文件失败:', err);
|
|
this.receivedMessages = [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 保存当前收件箱到磁盘。
|
|
*/
|
|
persistReceivedMessages() {
|
|
try {
|
|
fs.mkdirSync(path.dirname(this.receivedMessagesFile), { recursive: true });
|
|
const tmpFile = `${this.receivedMessagesFile}.${process.pid}.tmp`;
|
|
fs.writeFileSync(
|
|
tmpFile,
|
|
`${JSON.stringify(this.receivedMessages, null, 2)}\n`,
|
|
'utf8'
|
|
);
|
|
fs.renameSync(tmpFile, this.receivedMessagesFile);
|
|
} catch (err) {
|
|
logger.error('保存短信收件箱持久化文件失败:', err);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 规范化历史短信记录,避免坏数据影响Web展示。
|
|
*/
|
|
normalizeStoredMessage(item) {
|
|
if (!item || typeof item !== 'object' || Array.isArray(item)) {
|
|
return null;
|
|
}
|
|
|
|
const id = String(item.id || '').trim();
|
|
if (!id) {
|
|
return null;
|
|
}
|
|
|
|
const simIdentity = this.normalizeStoredSimIdentity(item);
|
|
|
|
return {
|
|
id,
|
|
sender: String(item.sender || '未知号码'),
|
|
text: String(item.text || ''),
|
|
timestamp: String(item.timestamp || item.receivedAt || new Date().toISOString()),
|
|
receivedAt: String(item.receivedAt || new Date().toISOString()),
|
|
simId: simIdentity?.simId || '',
|
|
simLabel: simIdentity?.simLabel || '未知SIM',
|
|
simSlot: simIdentity?.simSlot ?? null,
|
|
simSource: simIdentity?.simSource || item.simSource || 'unknown',
|
|
smsCenter: '',
|
|
status: String(item.status || 'received'),
|
|
statusText: String(item.statusText || '已接收')
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 从历史记录中恢复SIM身份。旧记录没有SIM字段,会归到未知SIM。
|
|
*/
|
|
normalizeStoredSimIdentity(item) {
|
|
if (item.simId || item.simLabel) {
|
|
return this.normalizeSimIdentity({
|
|
simId: item.simId,
|
|
simLabel: item.simLabel,
|
|
simSlot: item.simSlot,
|
|
simSource: item.simSource
|
|
});
|
|
}
|
|
|
|
if (item.sim && typeof item.sim === 'object') {
|
|
return this.normalizeSimIdentity(item.sim);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* 获取当前SIM身份,用于收到短信时打标和Web收件箱过滤。
|
|
*/
|
|
async getCurrentSimIdentity(options = {}) {
|
|
const refresh = options.refresh === true;
|
|
const now = Date.now();
|
|
|
|
if (!refresh && this.currentSimCheckedAt && now - this.currentSimCheckedAt < this.simIdentityTtlMs) {
|
|
return this.currentSimIdentity;
|
|
}
|
|
|
|
if (this.currentSimLookup) {
|
|
return this.currentSimLookup;
|
|
}
|
|
|
|
if (this.modem?.isSimSwitching?.()) {
|
|
return this.currentSimIdentity;
|
|
}
|
|
|
|
const modemSim = this.getCurrentModemSimIdentity();
|
|
if (modemSim?.simId) {
|
|
return this.setCurrentSimIdentity(modemSim);
|
|
}
|
|
|
|
this.currentSimIdentity = null;
|
|
this.currentSimCheckedAt = now;
|
|
return null;
|
|
}
|
|
|
|
getCurrentModemSimIdentity() {
|
|
const simStatus = this.modem?.getCachedSimStatus?.();
|
|
const activeSlot = simStatus?.activeSlot;
|
|
if (activeSlot === null || activeSlot === undefined) {
|
|
return this.currentSimIdentity;
|
|
}
|
|
|
|
const identity = this.createSimIdentityFromSlot(activeSlot, {
|
|
simSource: 'current_slot'
|
|
});
|
|
return identity || this.currentSimIdentity;
|
|
}
|
|
|
|
/**
|
|
* 更新当前SIM身份缓存。
|
|
*/
|
|
setCurrentSimIdentity(identity) {
|
|
this.currentSimIdentity = this.normalizeSimIdentity(identity);
|
|
this.currentSimCheckedAt = Date.now();
|
|
return this.currentSimIdentity;
|
|
}
|
|
|
|
normalizeSimIdentity(identity) {
|
|
if (!identity) {
|
|
return null;
|
|
}
|
|
|
|
if (typeof identity === 'string') {
|
|
return null;
|
|
}
|
|
|
|
if (typeof identity !== 'object' || Array.isArray(identity)) {
|
|
return null;
|
|
}
|
|
|
|
const simSlot = this.normalizeSimSlot(identity.simSlot ?? identity.slot);
|
|
const simId = String(identity.simId || identity.id || '').trim();
|
|
if (!simId && simSlot === null) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
simId: simId || crypto.createHash('sha256').update(`slot:${simSlot}`).digest('hex').slice(0, 24),
|
|
simLabel: String(identity.simLabel || identity.label || (simSlot === null ? '未知SIM' : `SIM${simSlot + 1}`)),
|
|
simSlot,
|
|
simSource: String(identity.simSource || identity.source || 'unknown'),
|
|
smsCenter: ''
|
|
};
|
|
}
|
|
|
|
}
|
|
|
|
export default SMSProcessor;
|