aboutsummaryrefslogtreecommitdiffstats
path: root/src/wamail/store.js
diff options
context:
space:
mode:
authorElvis Claros Castro <elvis@claros.ar>2026-09-26 19:26:33 -0300
committerElvis Claros Castro <elvis@claros.ar>2026-09-26 19:26:33 -0300
commitd80640d01eeb84907e36ff4af33add9081eda17c (patch)
tree27698b34e06e28d111c2196700015d9c82f78274 /src/wamail/store.js
downloadwa-cli-main.tar.gz
wa-cli-main.zip
wa-cli: WhatsApp from the terminal, with a neomutt Maildir bridgeHEADmain
Diffstat (limited to 'src/wamail/store.js')
-rw-r--r--src/wamail/store.js172
1 files changed, 172 insertions, 0 deletions
diff --git a/src/wamail/store.js b/src/wamail/store.js
new file mode 100644
index 0000000..fc8a6e0
--- /dev/null
+++ b/src/wamail/store.js
@@ -0,0 +1,172 @@
+// Per-chat Maildir + persistent state + lock, for wamail.
+//
+// <maildir>/<chat-dir>/{new,cur,tmp}/<ts>.<stanzaId>.wa[:2,<flags>]
+// ~/.local/state/wamail/<account>.json last synced state (base for the diff)
+// ~/.local/state/wamail/<account>.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();
+ }
+}