OASIS_MOBILE/src/models/phone_model.js
oasis-bot c332afd6ea upstream: integrar Oasis 1.3.1
Base del merge a tres bandas: git 0.9.5 de epsylon, esta rama y el src de la
APK oficial 1.3.1 (la variante movil: OASIS_MOBILE, regex sin \p{}, audio y
video de Android). Corre en el mismo nodejs-mobile 18.20.4 que ya usabamos.

Se conserva de la rama: Karvan, identidades y multicuenta, topbar y barra
inferior, borrado de EXIF sin sharp, filtro hot del foro, RTC sin STUN y los
modulos apagados en movil (wallet, banking, torrents).

Se toma de upstream lo que ya hace de serie: respuesta citada en chats, ssb-lan
en movil, configuracion en ~/.ssb/oasis (sobrevive a reinstalar el backend).
El asistente de bienvenida deja elegir workflow tambien en movil.
2026-10-18 02:52:17 +02:00

325 lines
13 KiB
JavaScript

const fs = require('fs');
const pull = require('../server/node_modules/pull-stream');
const sharedState = require('../configs/shared-state');
const { notify: desktopNotify, i18nNow, displayName } = require('../backend/desktopNotify');
const { statePath } = require('../configs/state-manager');
const HISTORY_MAX = 200;
const HEARD_MAX = 1000;
const LATEST_MAX = 5;
const PUBS_TTL_MS = 5 * 60 * 1000;
const RECOUNT_DELAY_MS = 500;
const FEED_ID = /^@[A-Za-z0-9+/]{43}=\.ed25519$/;
const readJson = (p, fallback) => { try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch (_) { return fallback; } };
const writeJson = (p, data) => { try { fs.writeFileSync(p, JSON.stringify(data, null, 2)); } catch (_) {} };
const asP = (fn, ...args) => new Promise((resolve, reject) => fn(...args, (err, v) => err ? reject(err) : resolve(v)));
const rpcValue = (fn, ...args) => new Promise((resolve) => {
try {
const r = fn(...args, (err, v) => resolve(err ? null : v));
if (r && typeof r.then === 'function') r.then(resolve, () => resolve(null));
else if (r !== undefined) resolve(r);
} catch (_) { resolve(null); }
});
module.exports = ({ cooler, pmModel, nameOf, isPublic, encryptFile, isAvailable, prefetch }) => {
const historyFile = statePath('phone-history.json');
const seenFile = statePath('phone-seen.json');
let ssb = null;
let subscribed = false;
let pubsAt = 0;
let pubsTimer = null;
const open = async () => { if (!ssb) ssb = await cooler.open(); return ssb; };
const phone = async () => { const s = await open(); return s && s.phone ? s.phone : null; };
const history = () => { const h = readJson(historyFile, []); return Array.isArray(h) ? h : []; };
const seen = () => {
const s = readJson(seenFile, {});
return { missedSeenAt: Number(s.missedSeenAt) || 0, heard: Array.isArray(s.heard) ? s.heard : [] };
};
const refreshCount = async () => {
let n = 0;
const st = sharedState.getPhoneCall ? sharedState.getPhoneCall() : null;
if (st && st.dir === 'in' && st.phase === 'incoming') n += 1;
const sn = seen();
n += history().filter(h => h && h.outcome === 'missed' && h.endedAt > sn.missedSeenAt).length;
try {
const me = (await open()).id;
const heard = new Set(sn.heard);
n += (await pmModel.listPams()).filter(m => m.value.author !== me && !heard.has(m.key)).length;
} catch (_) {}
sharedState.setPhoneCount(n);
return n;
};
const notify = async (peer, callId) => {
const i18n = i18nNow();
let name = '';
try { name = await nameOf(peer); } catch (_) {}
desktopNotify(i18n.phoneTitle, `${displayName(peer, name)} ${i18n.phoneIsCalling}`, callId ? `call:${callId}` : null, {
timeoutMs: require('../server/phone_module').RING_MS,
actions: [{ key: 'answer', label: i18n.phoneAnswer }, { key: 'reject', label: i18n.phoneReject }],
onAction: async (chosen) => {
try {
const ph = await phone();
const st = ph ? await rpcValue(ph.state) : null;
if (!st || st.id !== callId || st.phase !== 'incoming') return;
await act(chosen === 'answer' ? 'accept' : 'reject');
} catch (_) {}
}
});
};
const ROOM_EVENT_TEXT = { join: 'roomEventJoined', leave: 'roomEventLeft', recStart: 'roomEventRecStart', recStop: 'roomEventRecStop', mute: 'roomEventMuted', unmute: 'roomEventUnmuted', hand: 'roomEventHand' };
const roomNotices = { rid: null, since: 0, seen: new Set() };
const announceRoom = async (room) => {
if (!room || !room.rid) { roomNotices.rid = null; roomNotices.seen = new Set(); return; }
if (roomNotices.rid !== room.rid) { roomNotices.rid = room.rid; roomNotices.since = Number(room.joinedAt) || Date.now(); roomNotices.seen = new Set(); }
if (!room.notify) { roomNotices.since = Date.now() + 1; return; }
const events = Array.isArray(room.events) ? room.events : [];
const keyOf = (e) => `${e.ts}:${e.t}:${e.id}`;
const fresh = events.filter(e => e && ROOM_EVENT_TEXT[e.t] && !roomNotices.seen.has(keyOf(e)) && Number(e.ts) >= roomNotices.since);
roomNotices.seen = new Set(events.filter(Boolean).map(keyOf));
if (!fresh.length) return;
let me = null;
try { me = (await open()).id; } catch (_) {}
const i18n = i18nNow();
for (const e of fresh) {
if (e.id === me) continue;
let name = '';
try { name = await nameOf(e.id); } catch (_) {}
desktopNotify(i18n.roomsTitle, String(i18n[ROOM_EVENT_TEXT[e.t]] || '{name}').replace('{name}', displayName(e.id, name)));
}
};
const subscribe = async () => {
if (subscribed) return;
const ph = await phone();
if (!ph || subscribed) return;
subscribed = true;
pull(ph.events(), pull.drain((ev) => {
if (!ev) return;
if (ev.type === 'state') {
sharedState.setPhoneCall(ev.state || null);
refreshCount();
} else if (ev.type === 'room') {
sharedState.setPhoneRoom(ev.room || null);
announceRoom(ev.room).catch(() => {});
} else if (ev.type === 'recordLimit') {
finishPam().catch(() => {});
} else if (ev.type === 'incoming') {
notify(ev.peer, ev.id);
} else if (ev.type === 'ended' && ev.call) {
const h = history();
h.unshift(ev.call);
writeJson(historyFile, h.slice(0, HISTORY_MAX));
refreshCount();
}
}, () => {
subscribed = false;
const retry = setTimeout(() => { subscribe().catch(() => {}); }, 5000);
if (retry.unref) retry.unref();
}));
try { sharedState.setPhoneCall((await rpcValue(ph.state)) || null); } catch (_) {}
try { if (typeof ph.roomState === 'function') sharedState.setPhoneRoom((await rpcValue(ph.roomState)) || null); } catch (_) {}
};
let arrivalsWatched = false;
let recountTimer = null;
const scheduleRecount = () => {
if (recountTimer) return;
recountTimer = setTimeout(() => { recountTimer = null; refreshCount().catch(() => {}); }, RECOUNT_DELAY_MS);
if (recountTimer.unref) recountTimer.unref();
};
const watchArrivals = async () => {
if (arrivalsWatched) return;
const s = await open();
if (!s || !s.db || typeof s.db.onMsgAdded !== 'function') return;
arrivalsWatched = true;
s.db.onMsgAdded((ev) => {
const v = ev && ev.kvt && ev.kvt.value;
if (!v || v.author === s.id || typeof v.content !== 'string') return;
scheduleRecount();
}, false);
};
const act = async (method, ...args) => {
await subscribe();
const ph = await phone();
if (!ph) throw new Error('unavailable');
return asP(ph[method], ...args);
};
const finishPam = async () => {
const ph = await phone();
if (!ph) return false;
const out = await asP(ph.recordStop);
if (!out || !out.file) return false;
try {
const enc = await encryptFile(out.file);
await pmModel.sendPam(out.peer, enc.cipher, Number(out.durationMs) / 1000);
return true;
} finally {
fs.unlink(out.file, () => {});
}
};
const findPam = async (key) => (await pmModel.listPams()).find(m => m.key === key) || null;
const byType = (s, type) => new Promise((resolve) => {
if (!s || typeof s.messagesByType !== 'function') return resolve([]);
try { pull(s.messagesByType({ type }), pull.collect((err, msgs) => resolve(err ? [] : msgs))); } catch (_) { resolve([]); }
});
const defaultPubId = () => {
const m = String((require('../configs/config-manager').defaultInvite() || {}).code || '').match(/(@[A-Za-z0-9+/]{43}=\.ed25519)/);
return m ? m[1] : '';
};
let announcedPubs = new Set();
const refreshPubs = async (force = false) => {
const ids = new Set();
const add = (id) => { if (FEED_ID.test(String(id || ''))) ids.add(String(id)); };
try {
const s = await open();
if (force || Date.now() - pubsAt >= PUBS_TTL_MS) {
pubsAt = Date.now();
const found = new Set();
for (const m of await byType(s, 'pub')) { const v = m && m.value; const k = v && v.content && v.content.address && v.content.address.key; if (FEED_ID.test(String(k || '')) && (v.author === k || v.author === s.id)) found.add(String(k)); }
for (const m of await byType(s, 'pubAvailability')) { const k = m && m.value && m.value.author; if (FEED_ID.test(String(k || ''))) found.add(String(k)); }
announcedPubs = found;
}
add(defaultPubId());
for (const id of announcedPubs) add(id);
try { const ph = s.phone; if (ph && typeof ph.pubs === 'function') for (const id of (await rpcValue(ph.pubs)) || []) add(id); } catch (_) {}
ids.delete(s.id);
} catch (_) { add(defaultPubId()); }
sharedState.setPubIds(ids);
return ids;
};
const isPub = (id) => sharedState.isPubId(id);
return {
async start() {
await subscribe();
await watchArrivals().catch(() => {});
await refreshCount();
await refreshPubs(true);
if (!pubsTimer) { pubsTimer = setInterval(() => { refreshPubs(true).catch(() => {}); }, PUBS_TTL_MS); if (pubsTimer.unref) pubsTimer.unref(); }
},
refreshCount,
refreshPubs,
isPub,
async available() {
if (isPublic) return false;
const ph = await phone();
return !!(ph && await rpcValue(ph.available));
},
async state() {
await subscribe();
const ph = await phone();
return ph ? rpcValue(ph.state) : null;
},
async call(to, opts = {}) {
await refreshPubs();
const list = Array.isArray(to) ? to : [to];
if (list.some(isPub)) throw new Error('pub');
return act('call', list.length === 1 ? list[0] : list, { legacy: Array.isArray(opts.legacy) ? opts.legacy : [] });
},
accept: () => act('accept'),
reject: () => act('reject'),
hangup: () => act('end'),
mute: (flag) => act('mute', !!flag),
camera: (flag) => act('camera', !!flag),
async videoFrames(peer) {
const ph = await phone();
return ph && typeof ph.videoFrames === 'function' ? ph.videoFrames({ peer: String(peer || '') }) : null;
},
silence: (id, flag) => act('silence', { id, on: !!flag }),
dismiss: () => act('dismiss'),
recordCancel: () => act('recordCancel'),
async sendPam() { await subscribe(); return finishPam(); },
history,
async markAllHeard(keys) {
const s = seen();
const set = new Set(s.heard);
for (const k of keys || []) if (typeof k === 'string' && k) set.add(k);
s.heard = [...set].slice(-HEARD_MAX);
writeJson(seenFile, s);
refreshCount();
},
deleteHistory(ids) {
const drop = new Set((ids || []).map(String));
if (!drop.size) return;
writeJson(historyFile, history().filter(h => !drop.has(String(h && h.id))));
},
latestCalls(items) {
const me = ssb && ssb.id;
const out = [];
const seenPeers = new Set();
for (const h of items || history()) {
for (const peer of (Array.isArray(h.peers) && h.peers.length ? h.peers : [h.peer])) {
if (!peer || peer === me || seenPeers.has(peer) || isPub(peer)) continue;
seenPeers.add(peer);
out.push({ id: peer, dir: h.dir, outcome: h.outcome, startedAt: h.startedAt });
if (out.length >= LATEST_MAX) return out;
}
}
return out;
},
async pams() {
const me = (await open()).id;
const heard = new Set(seen().heard);
const validShare = (s) => !!s && typeof s === 'object' && typeof s.key === 'string' && typeof s.manifestBlobId === 'string';
const list = (await pmModel.listPams())
.filter(m => m.value.author !== me && validShare(m.value.content && m.value.content.share))
.map(m => ({ key: m.key, from: m.value.author, sentAt: m.value.content.sentAt || new Date(m.timestamp || 0).toISOString(), durationSec: Number(m.value.content.durationSec) || 0, heard: heard.has(m.key), share: m.value.content.share, ready: true }))
.sort((a, b) => String(b.sentAt).localeCompare(String(a.sentAt)));
if (typeof isAvailable === 'function') {
for (const item of list) {
item.ready = await isAvailable(item.share).catch(() => false);
if (!item.ready && typeof prefetch === 'function') prefetch(item.share).catch(() => {});
}
}
return list.map(({ share, ...rest }) => rest);
},
async pamCipher(key) {
const m = await findPam(key);
const share = m && m.value && m.value.content && m.value.content.share;
if (!share || typeof share.key !== 'string' || typeof share.manifestBlobId !== 'string') return null;
return share;
},
markHeard(key) {
const s = seen();
if (s.heard.includes(key)) return false;
s.heard.push(key);
s.heard = s.heard.slice(-HEARD_MAX);
writeJson(seenFile, s);
refreshCount();
return true;
},
async deletePam(key) {
const m = await findPam(key);
if (!m) return null;
return pmModel.deleteMessageById(key);
},
async knownFeeds() {
const s = await open();
await refreshPubs();
const hops = await new Promise((resolve) => {
try { s.friends.hops({ start: s.id, max: 2 }, (err, h) => resolve(err ? {} : (h || {}))); } catch (_) { resolve({}); }
});
const out = new Map();
for (const [id, hop] of Object.entries(hops)) {
if (id === s.id || isPub(id) || !(Number(hop) >= 0)) continue;
out.set(id, Number(hop));
}
return out;
},
markMissedSeen() {
const s = seen();
s.missedSeenAt = Date.now();
writeJson(seenFile, s);
refreshCount();
}
};
};