import crypto from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; import { config } from '@social/core/config/index.js'; import { logger } from '@social/core/utils/logger.js'; import { DatabaseSync } from '@social/core/utils/sqlite.js'; import type { CommentNotification } from './types.js'; export type NotificationTaskStatus = | 'new' | 'pending' | 'replied' | 'failed' | 'ignored'; interface NotificationRow { fingerprint: string; user_id: string; nickname: string; avatar: string; content: string; type: string; time: string; feed_id: string; xsec_token: string; note_image: string; status: NotificationTaskStatus; first_seen_at: number; last_seen_at: number; retry_count: number; last_attempt_at: number | null; replied_at: number | null; reply_content: string | null; error_message: string | null; } export interface NotificationTask { fingerprint: string; notification: CommentNotification; status: NotificationTaskStatus; firstSeenAt: string; lastSeenAt: string; retryCount: number; lastAttemptAt?: string; repliedAt?: string; replyContent?: string; errorMessage?: string; } export interface NotificationUpsertResult { fetched: number; inserted: number; updated: number; } export interface NotificationKeysetCursor { firstSeenAt: number; fingerprint: string; } const PLATFORM = 'xiaohongshu'; const DB_FILENAME = 'automation.db'; const log = logger.child({ module: 'xhs-notification-state' }); export class NotificationStateStore { private readonly db: InstanceType; private readonly dbPath: string; constructor(baseDir = config.cookieDir, dbFilename = DB_FILENAME) { const dir = path.join(baseDir, PLATFORM); fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); this.dbPath = path.join(dir, dbFilename); this.db = new DatabaseSync(this.dbPath); this.db.exec('PRAGMA journal_mode = WAL;'); this.db.exec('PRAGMA synchronous = NORMAL;'); this.db.exec(` CREATE TABLE IF NOT EXISTS notification_tasks ( fingerprint TEXT PRIMARY KEY, user_id TEXT NOT NULL, nickname TEXT NOT NULL, avatar TEXT NOT NULL, content TEXT NOT NULL, type TEXT NOT NULL, time TEXT NOT NULL, feed_id TEXT NOT NULL, xsec_token TEXT NOT NULL, note_image TEXT NOT NULL, status TEXT NOT NULL, first_seen_at INTEGER NOT NULL, last_seen_at INTEGER NOT NULL, retry_count INTEGER NOT NULL DEFAULT 0, last_attempt_at INTEGER, replied_at INTEGER, reply_content TEXT, error_message TEXT ); CREATE INDEX IF NOT EXISTS idx_notification_tasks_status_first_seen ON notification_tasks(status, first_seen_at); CREATE INDEX IF NOT EXISTS idx_notification_tasks_user_content_status ON notification_tasks(user_id, content, status); `); log.info({ dbPath: this.dbPath }, 'Notification state store initialized'); } buildFingerprint(notification: CommentNotification): string { const payload = [ notification.feedId, notification.userId, notification.content.trim(), notification.time.trim(), notification.type.trim(), ].join('|'); return crypto.createHash('sha256').update(payload).digest('hex'); } upsertNotifications(notifications: CommentNotification[]): NotificationUpsertResult { if (notifications.length === 0) { return { fetched: 0, inserted: 0, updated: 0 }; } const now = Date.now(); let inserted = 0; let updated = 0; const selectStmt = this.db.prepare( 'SELECT fingerprint FROM notification_tasks WHERE fingerprint = ?', ); const insertStmt = this.db.prepare(` INSERT INTO notification_tasks ( fingerprint, user_id, nickname, avatar, content, type, time, feed_id, xsec_token, note_image, status, first_seen_at, last_seen_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'new', ?, ?) `); const updateStmt = this.db.prepare(` UPDATE notification_tasks SET nickname = ?, avatar = ?, type = ?, time = ?, feed_id = ?, xsec_token = ?, note_image = ?, last_seen_at = ? WHERE fingerprint = ? `); this.db.exec('BEGIN'); try { for (const n of notifications) { const fp = this.buildFingerprint(n); const exists = selectStmt.get(fp) as { fingerprint: string } | undefined; if (!exists) { insertStmt.run( fp, n.userId, n.nickname, n.avatar, n.content, n.type, n.time, n.feedId, n.xsecToken, n.noteImage, now, now, ); inserted++; } else { updateStmt.run( n.nickname, n.avatar, n.type, n.time, n.feedId, n.xsecToken, n.noteImage, now, fp, ); updated++; } } this.db.exec('COMMIT'); } catch (err) { this.db.exec('ROLLBACK'); throw err; } return { fetched: notifications.length, inserted, updated, }; } listByStatuses( statuses: NotificationTaskStatus[], maxCount: number, offset = 0, ): NotificationTask[] { if (statuses.length === 0) return []; const placeholders = statuses.map(() => '?').join(', '); const query = ` SELECT fingerprint, user_id, nickname, avatar, content, type, time, feed_id, xsec_token, note_image, status, first_seen_at, last_seen_at, retry_count, last_attempt_at, replied_at, reply_content, error_message FROM notification_tasks WHERE status IN (${placeholders}) ORDER BY first_seen_at ASC LIMIT ? OFFSET ? `; const stmt = this.db.prepare(query); const rows = stmt.all(...statuses, maxCount, offset) as unknown as NotificationRow[]; return rows.map((r) => this.rowToTask(r)); } listByStatusesKeyset( statuses: NotificationTaskStatus[], maxCount: number, cursor?: NotificationKeysetCursor, ): { tasks: NotificationTask[]; hasMore: boolean; nextCursor?: NotificationKeysetCursor } { if (statuses.length === 0 || maxCount <= 0) { return { tasks: [], hasMore: false }; } const placeholders = statuses.map(() => '?').join(', '); const condition = cursor ? ` AND ( first_seen_at > ? OR (first_seen_at = ? AND fingerprint > ?) ) ` : ''; const query = ` SELECT fingerprint, user_id, nickname, avatar, content, type, time, feed_id, xsec_token, note_image, status, first_seen_at, last_seen_at, retry_count, last_attempt_at, replied_at, reply_content, error_message FROM notification_tasks WHERE status IN (${placeholders}) ${condition} ORDER BY first_seen_at ASC, fingerprint ASC LIMIT ? `; const stmt = this.db.prepare(query); const limitWithSentinel = maxCount + 1; const rows = cursor ? stmt.all( ...statuses, cursor.firstSeenAt, cursor.firstSeenAt, cursor.fingerprint, limitWithSentinel, ) as unknown as NotificationRow[] : stmt.all(...statuses, limitWithSentinel) as unknown as NotificationRow[]; const hasMore = rows.length > maxCount; const pageRows = hasMore ? rows.slice(0, maxCount) : rows; const tasks = pageRows.map((r) => this.rowToTask(r)); if (!hasMore || pageRows.length === 0) { return { tasks, hasMore }; } const last = pageRows[pageRows.length - 1]!; return { tasks, hasMore, nextCursor: { firstSeenAt: last.first_seen_at, fingerprint: last.fingerprint, }, }; } countByStatuses(statuses: NotificationTaskStatus[]): number { if (statuses.length === 0) return 0; const placeholders = statuses.map(() => '?').join(', '); const query = ` SELECT COUNT(1) AS count FROM notification_tasks WHERE status IN (${placeholders}) `; const stmt = this.db.prepare(query); const row = stmt.get(...statuses) as { count?: number } | undefined; return row?.count ?? 0; } getByFingerprint(fingerprint: string): NotificationTask | null { const stmt = this.db.prepare(` SELECT fingerprint, user_id, nickname, avatar, content, type, time, feed_id, xsec_token, note_image, status, first_seen_at, last_seen_at, retry_count, last_attempt_at, replied_at, reply_content, error_message FROM notification_tasks WHERE fingerprint = ? LIMIT 1 `); const row = stmt.get(fingerprint) as NotificationRow | undefined; return row ? this.rowToTask(row) : null; } findOpenFingerprint(userId: string, content: string): string | null { const stmt = this.db.prepare(` SELECT fingerprint FROM notification_tasks WHERE user_id = ? AND content = ? AND status IN ('new', 'failed', 'pending') ORDER BY first_seen_at ASC LIMIT 1 `); const row = stmt.get(userId, content) as { fingerprint: string } | undefined; return row?.fingerprint ?? null; } markPending(fingerprint: string): void { const now = Date.now(); const stmt = this.db.prepare(` UPDATE notification_tasks SET status = 'pending', last_attempt_at = ?, error_message = NULL WHERE fingerprint = ? `); stmt.run(now, fingerprint); } markReplied(fingerprint: string, replyContent: string): void { const now = Date.now(); const stmt = this.db.prepare(` UPDATE notification_tasks SET status = 'replied', replied_at = ?, last_attempt_at = ?, reply_content = ?, error_message = NULL WHERE fingerprint = ? `); stmt.run(now, now, replyContent, fingerprint); } markFailed(fingerprint: string, errorMessage: string): void { const now = Date.now(); const stmt = this.db.prepare(` UPDATE notification_tasks SET status = 'failed', retry_count = retry_count + 1, last_attempt_at = ?, error_message = ? WHERE fingerprint = ? `); stmt.run(now, errorMessage, fingerprint); } markIgnored(fingerprint: string, reason?: string): void { const stmt = this.db.prepare(` UPDATE notification_tasks SET status = 'ignored', error_message = ? WHERE fingerprint = ? `); stmt.run(reason ?? 'Ignored by operator', fingerprint); } setStatus( fingerprint: string, status: NotificationTaskStatus, note?: string, ): void { const now = Date.now(); const stmt = this.db.prepare(` UPDATE notification_tasks SET status = ?, last_attempt_at = CASE WHEN ? IN ('pending', 'failed', 'replied') THEN ? ELSE last_attempt_at END, replied_at = CASE WHEN ? = 'replied' THEN ? ELSE replied_at END, error_message = CASE WHEN ? = 'failed' THEN COALESCE(?, 'Marked as failed') WHEN ? = 'ignored' THEN COALESCE(?, 'Ignored by operator') ELSE error_message END, reply_content = CASE WHEN ? = 'replied' THEN COALESCE(?, reply_content) ELSE reply_content END WHERE fingerprint = ? `); stmt.run( status, status, now, status, now, status, note ?? null, status, note ?? null, status, note ?? null, fingerprint, ); } private rowToTask(row: NotificationRow): NotificationTask { return { fingerprint: row.fingerprint, notification: { userId: row.user_id, nickname: row.nickname, avatar: row.avatar, content: row.content, type: row.type, time: row.time, feedId: row.feed_id, xsecToken: row.xsec_token, noteImage: row.note_image, }, status: row.status, firstSeenAt: new Date(row.first_seen_at).toISOString(), lastSeenAt: new Date(row.last_seen_at).toISOString(), retryCount: row.retry_count, ...(row.last_attempt_at ? { lastAttemptAt: new Date(row.last_attempt_at).toISOString() } : {}), ...(row.replied_at ? { repliedAt: new Date(row.replied_at).toISOString() } : {}), ...(row.reply_content ? { replyContent: row.reply_content } : {}), ...(row.error_message ? { errorMessage: row.error_message } : {}), }; } } let storeSingleton: NotificationStateStore | null = null; export function getNotificationStateStore(): NotificationStateStore { if (!storeSingleton) { storeSingleton = new NotificationStateStore(); } return storeSingleton; }