// Per-chat Maildir + persistent state + lock, for wamail. // // //{new,cur,tmp}/..wa[:2,] // ~/.local/state/wamail/.json last synced state (base for the diff) // ~/.local/state/wamail/.lock one sync at a time (timer, hook, neomutt macro) import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; const HOME = os.homedir(); export const STATE_DIR = path.join(process.env.XDG_STATE_HOME || path.join(HOME, '.local/state'), 'wamail'); export const CACHE_DIR = path.join(process.env.XDG_CACHE_HOME || path.join(HOME, '.cache'), 'wamail'); const FILE_RE = /^(\d+)\.([A-Za-z0-9_-]+)\.wa(?::2,([A-Za-z]*))?$/; export const expandHome = (p) => p.replace(/^~(?=$|\/)/, HOME); // ---------------------------------------------------------------- nombres export function slug(name) { return String(name || '') .normalize('NFD').replace(/\p{M}/gu, '') .toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') .slice(0, 32).replace(/-+$/, ''); } // Stable directory for a chat: chosen once and kept in the state even if the chat // name changes later. export function chatDirName(chatId, name, taken) { const user = chatId.split('@')[0].replace(/\D/g, ''); const base = slug(name) || (chatId.endsWith('@g.us') ? 'grupo' : 'chat'); for (let n = 6; n <= user.length + 1; n++) { const candidate = `${base}-${user.slice(-n) || n}`; if (!taken.has(candidate)) return candidate; } let i = 2; while (taken.has(`${base}-${user}-${i}`)) i++; return `${base}-${user}-${i}`; } export const stanzaFileId = (stanzaId) => String(stanzaId).replace(/[^A-Za-z0-9_-]/g, '_'); // ---------------------------------------------------------------- Maildir export function ensureMaildir(dir) { for (const sub of ['new', 'cur', 'tmp']) fs.mkdirSync(path.join(dir, sub), { recursive: true }); } // { [fileId]: { path, sub, flags: Set, ts } } — reads new/ before cur/ so a message that // neomutt moves from new to cur during the scan is not lost. export function scanDir(dir) { const out = {}; for (const sub of ['new', 'cur']) { let names; try { names = fs.readdirSync(path.join(dir, sub)); } catch { continue; } for (const name of names) { const m = FILE_RE.exec(name); if (!m) continue; const entry = { path: path.join(dir, sub, name), sub, flags: new Set(m[3] || ''), ts: Number(m[1]) }; const prev = out[m[2]]; if (prev) { // duplicate (hook/timer race): keep the one in cur/ const drop = prev.sub === 'cur' ? entry : prev; fs.rmSync(drop.path, { force: true }); if (drop === prev) out[m[2]] = entry; continue; } out[m[2]] = entry; } } return out; } export function writeMessage(dir, { fileId, ts, raw, seen }) { ensureMaildir(dir); const base = `${ts}.${fileId}.wa`; const tmp = path.join(dir, 'tmp', `${base}.${process.pid}`); const dest = seen ? path.join(dir, 'cur', `${base}:2,S`) : path.join(dir, 'new', base); fs.writeFileSync(tmp, raw); fs.utimesSync(tmp, ts, ts); fs.renameSync(tmp, dest); return { path: dest, sub: seen ? 'cur' : 'new', flags: new Set(seen ? 'S' : ''), ts }; } // Changes the flags of a file (moving it to cur/). Returns the updated entry. export function setFlags(dir, fileId, entry, flags) { const want = [...flags].sort().join(''); if (entry.sub === 'cur' && [...entry.flags].sort().join('') === want) return entry; const dest = path.join(dir, 'cur', `${entry.ts}.${fileId}.wa:2,${want}`); try { fs.renameSync(entry.path, dest); } catch (e) { if (e.code === 'ENOENT') return entry; // neomutt moved/deleted it meanwhile throw e; } return { ...entry, path: dest, sub: 'cur', flags: new Set(want) }; } // ---------------------------------------------------------------- estado export function statePath(account) { return path.join(STATE_DIR, `${account}.json`); } export function loadState(account) { try { const s = JSON.parse(fs.readFileSync(statePath(account), 'utf8')); if (s.version === 1) return { chats: {}, msgs: {}, tombstones: {}, ...s }; } catch (e) { if (e.code !== 'ENOENT') throw new Error(`corrupt state in ${statePath(account)}: ${e.message}`); } return { version: 1, chats: {}, msgs: {}, tombstones: {} }; } export function saveState(account, state) { fs.mkdirSync(STATE_DIR, { recursive: true }); const file = statePath(account); const tmp = `${file}.${process.pid}.tmp`; fs.writeFileSync(tmp, JSON.stringify(state)); fs.renameSync(tmp, file); } // ---------------------------------------------------------------- lock function alive(pid) { try { process.kill(pid, 0); return true; } catch (e) { return e.code === 'EPERM'; } } export async function withLock(account, { waitMs = 120000, onWait } = {}, fn) { fs.mkdirSync(STATE_DIR, { recursive: true }); const file = path.join(STATE_DIR, `${account}.lock`); const deadline = Date.now() + waitMs; let warned = false; for (;;) { try { fs.writeFileSync(file, `${process.pid}\n`, { flag: 'wx' }); break; } catch (e) { if (e.code !== 'EEXIST') throw e; const pid = Number.parseInt(fs.readFileSync(file, 'utf8'), 10); if (!pid || !alive(pid)) { fs.rmSync(file, { force: true }); continue; } if (Date.now() > deadline) throw new Error(`another wamail (pid ${pid}) has held the lock for too long`); if (!warned) onWait?.(pid); warned = true; await new Promise((r) => setTimeout(r, 300)); } } const release = () => { try { if (Number.parseInt(fs.readFileSync(file, 'utf8'), 10) === process.pid) fs.rmSync(file, { force: true }); } catch { /* already gone */ } }; process.once('exit', release); try { return await fn(); } finally { release(); } }