183 lines
4.4 KiB
TypeScript
183 lines
4.4 KiB
TypeScript
import Database from 'better-sqlite3';
|
||
import os from 'os';
|
||
import path from 'path';
|
||
|
||
const APPLE_EPOCH_MS = Date.UTC(2001, 0, 1);
|
||
const DB_PATH = path.join(os.homedir(), 'Library', 'Messages', 'chat.db');
|
||
|
||
export interface SmsMessage {
|
||
id: number;
|
||
text: string;
|
||
handle: string;
|
||
service: string;
|
||
isFromMe: boolean;
|
||
date: Date;
|
||
}
|
||
|
||
export interface WaitForCodeOptions {
|
||
timeoutMs?: number;
|
||
pollIntervalMs?: number;
|
||
logger?: (message: string) => void;
|
||
}
|
||
|
||
export interface WaitForCodeResult {
|
||
code: string;
|
||
message: SmsMessage;
|
||
}
|
||
|
||
interface RawMessageRow {
|
||
id: number;
|
||
text: string | null;
|
||
handle: string | null;
|
||
service: string | null;
|
||
is_from_me: number;
|
||
date_raw: number | null;
|
||
}
|
||
|
||
const DEFAULT_TIMEOUT_MS = 2 * 60 * 1000;
|
||
const DEFAULT_POLL_INTERVAL_MS = 2500;
|
||
|
||
function appleTimestampToDate(raw: number | null): Date {
|
||
if (!raw) {
|
||
return new Date(0);
|
||
}
|
||
|
||
let ms = raw;
|
||
if (raw > 1e15) {
|
||
ms = raw / 1_000_000;
|
||
} else if (raw > 1e12) {
|
||
ms = raw / 1_000;
|
||
} else {
|
||
ms = raw * 1000;
|
||
}
|
||
|
||
return new Date(APPLE_EPOCH_MS + ms);
|
||
}
|
||
|
||
function openDatabase(): Database.Database {
|
||
return new Database(DB_PATH, { readonly: true, fileMustExist: true });
|
||
}
|
||
|
||
function toSmsMessage(row: RawMessageRow | undefined): SmsMessage | null {
|
||
if (!row) {
|
||
return null;
|
||
}
|
||
|
||
const text = (row.text ?? '').trim();
|
||
if (!text) {
|
||
return null;
|
||
}
|
||
|
||
return {
|
||
id: row.id,
|
||
text,
|
||
handle: row.handle ?? '',
|
||
service: row.service ?? '',
|
||
isFromMe: row.is_from_me === 1,
|
||
date: appleTimestampToDate(row.date_raw),
|
||
};
|
||
}
|
||
|
||
function fetchLatestMessage(db: Database.Database): SmsMessage | null {
|
||
const stmt = db.prepare<[], RawMessageRow>(`
|
||
SELECT
|
||
message.ROWID AS id,
|
||
message.text AS text,
|
||
handle.id AS handle,
|
||
message.service AS service,
|
||
message.is_from_me AS is_from_me,
|
||
COALESCE(message.date, message.date_delivered, message.date_read) AS date_raw
|
||
FROM message
|
||
LEFT JOIN handle ON handle.ROWID = message.handle_id
|
||
WHERE message.text IS NOT NULL AND message.text != ''
|
||
ORDER BY date_raw DESC
|
||
LIMIT 1
|
||
`);
|
||
|
||
return toSmsMessage(stmt.get());
|
||
}
|
||
|
||
function fetchLatestDoubanMessage(db: Database.Database): SmsMessage | null {
|
||
const stmt = db.prepare<[], RawMessageRow>(`
|
||
SELECT
|
||
message.ROWID AS id,
|
||
message.text AS text,
|
||
handle.id AS handle,
|
||
message.service AS service,
|
||
message.is_from_me AS is_from_me,
|
||
COALESCE(message.date, message.date_delivered, message.date_read) AS date_raw
|
||
FROM message
|
||
LEFT JOIN handle ON handle.ROWID = message.handle_id
|
||
WHERE message.is_from_me = 0
|
||
AND message.text IS NOT NULL
|
||
AND message.text != ''
|
||
AND message.text LIKE '%豆瓣%'
|
||
AND message.text LIKE '%验证码%'
|
||
ORDER BY date_raw DESC
|
||
LIMIT 1
|
||
`);
|
||
|
||
return toSmsMessage(stmt.get());
|
||
}
|
||
|
||
function delay(ms: number): Promise<void> {
|
||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||
}
|
||
|
||
export function parseDoubanSms(text: string | null | undefined): string | null {
|
||
if (!text) {
|
||
return null;
|
||
}
|
||
|
||
const match = text.match(/验证码[::]\s*([0-9]{4,6})/);
|
||
return match ? match[1] : null;
|
||
}
|
||
|
||
export async function waitForDoubanCode(options: WaitForCodeOptions = {}): Promise<WaitForCodeResult> {
|
||
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
||
const pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
|
||
const deadline = Date.now() + timeoutMs;
|
||
const logger = options.logger;
|
||
|
||
let db: Database.Database | null = null;
|
||
try {
|
||
db = openDatabase();
|
||
const baselineMessage = fetchLatestMessage(db);
|
||
const baselineId = baselineMessage?.id ?? 0;
|
||
|
||
if (logger) {
|
||
logger(`已连接 chat.db,起始消息 ID: ${baselineId}`);
|
||
}
|
||
|
||
while (Date.now() <= deadline) {
|
||
const doubanMessage = fetchLatestDoubanMessage(db);
|
||
|
||
if (doubanMessage && doubanMessage.id > baselineId) {
|
||
const code = parseDoubanSms(doubanMessage.text);
|
||
if (code) {
|
||
if (logger) {
|
||
logger(`捕获验证码短信,消息 ID: ${doubanMessage.id}`);
|
||
}
|
||
|
||
return {
|
||
code,
|
||
message: doubanMessage,
|
||
};
|
||
}
|
||
}
|
||
|
||
if (logger) {
|
||
logger('未检测到新的豆瓣验证码短信,等待后重试...');
|
||
}
|
||
|
||
await delay(pollIntervalMs);
|
||
}
|
||
} finally {
|
||
if (db) {
|
||
db.close();
|
||
}
|
||
}
|
||
|
||
throw new Error('在设定的时间内未检测到新的豆瓣验证码短信');
|
||
}
|