1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
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();
}
}
|