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 { 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 { 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('在设定的时间内未检测到新的豆瓣验证码短信'); }