');
if (tab && headerEnd > 0) ctx.body = ctx.body.slice(0, headerEnd).replace(`
{
if (ctx.method === 'GET' && /^recent$/i.test(String(ctx.query.filter || '')) && ['q', 'search'].some(k => String(ctx.query[k] || '').trim())) ctx.query = { ...ctx.query, filter: ctx.query.filter === 'RECENT' ? 'ALL' : 'all' };
await next();
},
async (ctx, next) => { await require('../models/typed_log').requestScope.run({ capped: false, limit: 0, path: ctx.path, query: ctx.querystring, method: ctx.method }, next); },
async (ctx, next) => {
if (ctx.method !== 'POST') return next();
const forget = () => {
censusCache.clear();
lifetime.forget();
try { activityModel.invalidateCache(); } catch (_) {}
clearnetDecisionCache = { at: 0, data: null };
clearnetIndexCache = { ts: 0, data: null, building: null };
clearnetBlobCache = { ts: 0, set: null, forcedAt: clearnetBlobCache.forcedAt };
clearnetTribesCache = { at: 0, data: null };
};
forget();
try { await next(); } finally { forget(); await settleLogWrites(); }
},
publicModeGuard({
isPublic: !!config.public,
onBlocked: (ctx) => sendErrorPage(ctx, "Sorry, many actions are unavailable when Oasis is running in public mode. Please run Oasis in the default mode and try again.", { status: 403 })
}),
async (ctx, next) => {
if (isLoopbackOnlyPath(ctx.path) && !isLoopbackRequest(ctx)) { sendErrorPage(ctx, 'Forbidden', { status: 403 }); return; }
await next();
},
syncTribeModuleExposure,
async (ctx, next) => { applyFirstRunLanguage(ctx); setLanguage(isClearnetPath(ctx.request) ? clearnetLanguage(ctx) : (ctx.cookies.get("language") || getConfig().language || "en")); await next(); },
async (ctx, next) => {
try { require('../views/comments_view').setCommentsOpen(ctx.method === 'GET' && String(ctx.query.comments || '') === 'open'); } catch (_) {}
await next();
},
async (ctx, next) => {
if (ctx.method === 'GET' && WALLET_CHIP_PATHS.some(p => ctx.path === p || ctx.path.startsWith(`${p}/`))) {
await settleWithin(refreshWalletReady(), WALLET_CHECK_WAIT_MS);
const kind = (ctx.path === '/transfers' || ctx.path.startsWith('/transfers/')) ? 'transfers'
: (ctx.path === '/banking' || ctx.path.startsWith('/banking/')) ? 'banking' : null;
if (kind) require('../models/typed_log').requestScope.run({ capped: false, limit: 0, path: '/', query: '' }, () => runWalletWork(kind)).catch(() => {});
}
await next();
},
async (ctx, next) => {
await next();
const flash = String((ctx.state && ctx.state.inlineError) || (ctx.method === 'GET' ? signedErrorOf(ctx) : '')).trim();
if (!flash || typeof ctx.body !== 'string' || !/html/.test(String(ctx.type || ''))) return;
const { renderInlineError } = require('../views/main_views');
const marker = '';
const at = ctx.body.indexOf(marker);
if (at < 0) return;
const cleanUrl = ctx.method === 'GET' ? ctx.path + (() => { const q = new URLSearchParams(ctx.querystring); q.delete('error'); q.delete('errsig'); const s = q.toString(); return s ? `?${s}` : ''; })() : null;
ctx.body = ctx.body.slice(0, at + marker.length) + renderInlineError(flash, cleanUrl) + ctx.body.slice(at + marker.length);
},
async (ctx, next) => {
const isBinary = ctx.path.startsWith('/qr') || ctx.path.startsWith('/wallet/qr/') || ctx.path.startsWith('/image/') || ctx.path.startsWith('/blob/') || ctx.path.startsWith('/assets/');
if (isBinary) {
try { await next(); } catch (err) {
ctx.status = err.status || 500;
ctx.body = '';
}
return;
}
const allowDuringSync =
ctx.path === '/profile/edit' ||
ctx.path === '/profile' ||
ctx.path === '/modules' ||
ctx.path.startsWith('/c/') ||
ctx.path.startsWith('/settings');
if (allowDuringSync) {
try { await next(); } catch (err) {
const { i18n } = require('../views/main_views');
sendErrorPage(ctx, err.message || 'Internal Server Error', { status: err.status || 500 });
}
return;
}
const migration = typeof SSBconfig.migrationStatus === 'function' ? SSBconfig.migrationStatus() : null;
if (migration && migration.running) { ctx.response.body = indexingView({ percent: migration.percent }); return; }
const until = Date.now() + STARTUP_WAIT_MS;
const ssb = await Promise.race([cooler.open(), new Promise((resolve) => setTimeout(() => resolve(null), STARTUP_WAIT_MS))]);
let status = ssb ? await ssb.status() : { ready: false };
while (ssb && status && status.ready === false && Date.now() < until) {
await new Promise((resolve) => setTimeout(resolve, 150));
status = await ssb.status();
}
if (status && status.ready === false) ctx.response.body = indexingView({ percent: startupProgress(status), refresh: 5 });
else if (indexingLag(status) > INDEXING_LAG_BYTES) ctx.response.body = indexingView({ percent: Math.floor(indexingProgress(status) * 1000) / 10 });
else { try { await next(); } catch (err) {
const { i18n } = require('../views/main_views');
if (err.name === 'FileTooLargeError' || (err.message && err.message.includes('maxFileSize'))) {
sendErrorPage(ctx, i18n.fileTooLargeMessage, { title: i18n.fileTooLargeTitle, status: 413 });
return;
}
const moduleHome = moduleHomeFor(ctx);
if (moduleHome && isMissingContentError(err)) { ctx.redirect(moduleHome); return; }
sendErrorPage(ctx, err.message || 'Internal Server Error', { status: err.status || 500 });
} }
},
async (ctx, next) => {
if (!ctx.path.startsWith('/assets/') && !ctx.path.startsWith('/image/') && !ctx.path.startsWith('/blob/') && !ctx.path.startsWith('/qr') && !ctx.path.startsWith('/c/')) {
// filtro de la home (Personal/Community); lo leen la topbar y el lanzador
global.__OASIS_HIVE_FILTER__ = (ctx.path === '/activity' && (ctx.query.hive === 'personal' || ctx.query.hive === 'community')) ? ctx.query.hive : '';
// el lanzador de modulos SOLO en la home; en el resto de paginas el top queda limpio
global.__OASIS_SHOW_HIVE__ = (ctx.path === '/activity' || ctx.path === '/');
if (Date.now() - sharedState.getLastRefresh() > 60000) kickRefresh();
}
await next();
},
// rutas propias de la rama (Karvan, barra inferior, exportacion): fuera de la cadena de upstream
require('./karvan_routes')({ cooler, pull, pmModel, checkMod, getViewerId,
getConfig, saveConfig,
isLoopbackRequest, sendErrorPage }),
routes,
async (ctx) => {
if (ctx.body != null || (ctx.method !== 'GET' && ctx.method !== 'HEAD')) return;
if (isClearnetPath(ctx.request) || ctx.path === '/clearnet' || ctx.path.startsWith('/clearnet/')) {
ctx.status = 404;
ctx.type = 'text/html';
ctx.body = require('../views/clearnet_view').renderClearnetNotFound();
return;
}
if (/^\/(blob|image|assets|qr|qr-invite)(\/|$)/.test(ctx.path)) return;
ctx.redirect('/');
},
];
const app = http({ host, port, middleware, allowHost: config.allowHost });
if (ADMIN_TOKEN) console.log(`- Admin access (open it on this device): http://localhost:${port}/admin-session/${ADMIN_TOKEN}`);
const startDesktopNotices = async () => {
const { notify: desktopNotify, i18nNow, displayName } = require('./desktopNotify');
const ssb = await cooler.open();
if (!ssb || !ssb.db || typeof ssb.db.onMsgAdded !== 'function') return;
const me = ssb.id;
let lastEmergency = null;
try { const f = await emergenciesModel.featured(); lastEmergency = f && f.id; } catch (_) {}
const nameFor = async (id) => { try { return displayName(id, await about.name(id)); } catch (_) { return String(id); } };
const isMutual = async (id) => { try { const rel = await friend.getRelationship(id); return !!(rel && rel.following && rel.followsMe); } catch (_) { return false; } };
ssb.db.onMsgAdded((ev) => {
const kvt = ev && ev.kvt;
const v = kvt && kvt.value;
if (!v || v.author === me || Date.now() - (Number(v.timestamp) || 0) > 15 * 60 * 1000) return;
(async () => {
const i18n = i18nNow();
const cfg = getConfig();
if (typeof v.content === 'string') {
let c = null;
try { const dec = ssb.private.unbox({ key: kvt.key, value: v, timestamp: kvt.timestamp }); c = dec && dec.value && dec.value.content; } catch (_) {}
if (!c || c.private !== true || !Array.isArray(c.to) || !c.to.includes(me)) return;
if (cfg.pmVisibility === 'mutuals' && !(await isMutual(v.author))) return;
if (cfg.pmVisibility === 'following') {
let rel = null;
try { rel = await friend.getRelationship(v.author); } catch (_) { rel = null; }
if (!(rel && rel.following)) return;
}
if (c.type === 'pam') {
if (cfg.modules.phoneMod !== 'off') desktopNotify(i18n.phoneTitle, `${i18n.notifyNewPamFrom} ${await nameFor(v.author)}`);
return;
}
if (c.type !== 'post') return;
const bot = pmModel.botOf({ subject: String(c.subject || '') });
if (bot && pmModel.mutedBots(cfg).has(bot)) return;
desktopNotify(i18n.inbox, `${i18n.notifyNewPmFrom} ${bot || await nameFor(v.author)}`);
return;
}
const t = v.content && v.content.type;
if (typeof t === 'string' && t.startsWith('emergency') && cfg.modules.emergenciesMod === 'on') {
const f = await emergenciesModel.featured().catch(() => null);
if (!f || !f.id || f.id === lastEmergency) return;
lastEmergency = f.id;
if (sharedState.getDismissedEmergency && sharedState.getDismissedEmergency() === f.id) return;
desktopNotify(i18n.emergenciesTitle, f.title);
}
})().catch(() => {});
}, false);
};
if (!config.public) setTimeout(() => { phoneModel.start().catch(() => {}); startDesktopNotices().catch(() => {}); syncClearnetSince().catch(() => {}); }, 3000);
let pubEngineTimer = null;
let pubAddressEnsured = false;
let pubEngineRunning = false;
async function runPubEngineTick() {
if (!bankingModel.isPubNode() || pubEngineRunning) return;
pubEngineRunning = true;
try {
if (!pubAddressEnsured) { try { const r = await bankingModel.ensureSelfAddressPublished(); pubAddressEnsured = r && r.status !== 'skipped'; } catch (_) {} }
try { await bankingModel.executeEpoch({}); } catch (_) {}
try { await bankingModel.processPendingClaims(); } catch (_) {}
try { await bankingModel.confirmIncomingTransfers(); } catch (_) {}
try { await bankingModel.rebalanceUbiPools(); } catch (_) {}
try { await bankingModel.publishPubAvailability(); } catch (_) {}
} finally {
pubEngineRunning = false;
}
}
const ubiNoticePath = process.env.OASIS_BANKING_DIR ? path.join(process.env.OASIS_BANKING_DIR, 'banking-ubi-notice.json') : stateFilePath('banking-ubi-notice.json');
const confirmNoticePath = process.env.OASIS_BANKING_DIR ? path.join(process.env.OASIS_BANKING_DIR, 'banking-confirm-notice.json') : stateFilePath('banking-confirm-notice.json');
async function notifyPendingConfirmations() {
if (getConfig().modules?.transfersMod === 'off') return;
try {
const me = getViewerId();
const list = await transfersModel.listAll('all', me).catch(() => []);
const pending = (list || []).filter(t => {
const tags = Array.isArray(t.tags) ? t.tags.map(x => String(x).toUpperCase()) : [];
const settledUbi = tags.includes('UBI') && /^[0-9a-f]{64}$/i.test(String(t.txid || ''));
return String(t.status || '').toUpperCase() === 'UNCONFIRMED'
&& String(t.to) === String(me)
&& !(Array.isArray(t.confirmedBy) ? t.confirmedBy : []).includes(me)
&& !tags.includes('PENDING')
&& !settledUbi;
});
if (!pending.length) return;
let notified = [];
try { notified = JSON.parse(fs.readFileSync(confirmNoticePath, 'utf8')) || []; } catch (_) {}
const fresh = pending.filter(t => !notified.includes(t.id));
if (!fresh.length) return;
const { i18n: i18nB } = require('../views/main_views');
const lines = fresh.slice(0, 5).map(t => `ยท [${t.concept || t.id}](/transfers/${encodeURIComponent(t.id)}) โ ${Number(t.amount || 0).toFixed(6)} ECO`).join('\n');
await notifyBot('BANKING_CONFIRM_PENDING', [me], `${i18nB.bankingBotConfirmText} (${fresh.length}):\n${lines}\n\n[${i18nB.transfersFilterPending}](/transfers?filter=pending)`);
fs.writeFileSync(confirmNoticePath, JSON.stringify([...notified, ...fresh.map(t => t.id)].slice(-500)));
} catch (_) {}
}
const lastWalletWork = { transfers: 0, banking: 0 };
async function runWalletWork(kind) {
if (bankingModel.isPubNode()) return;
if (Date.now() - (lastWalletWork[kind] || 0) < 60000) return;
lastWalletWork[kind] = Date.now();
try { await bankingModel.confirmIncomingTransfers(); } catch (_) {}
if (kind === 'transfers') { try { await notifyPendingConfirmations(); } catch (_) {} return; }
try {
const avail = await bankingModel.claimAvailability(getViewerId());
if (!avail.available) return;
let notified = {};
try { notified = JSON.parse(fs.readFileSync(ubiNoticePath, 'utf8')) || {}; } catch (_) {}
if (notified.epochId === avail.epochId) return;
const { i18n: i18nB } = require('../views/main_views');
await notifyBot('BANKING_UBI_AVAILABLE', [getViewerId()], `${i18nB.bankingBotUbiAvailableText}: [${i18nB.bankClaimUBI}](/banking?filter=exchange) ยท ${avail.epochId}`);
fs.writeFileSync(ubiNoticePath, JSON.stringify({ epochId: avail.epochId, at: new Date().toISOString() }));
} catch (_) {}
}
const CLEARNET_ROOMS_SYNC_MS = 60 * 1000;
const CLEARNET_ROOM_WATCHERS_MAX = 50;
const SOUND_RATE = require('../server/phone_module').SOUND_RATE;
const SOUND_BACKLOG_MAX = SOUND_RATE * 2 * 2;
const clearnetRoomWatchers = new Map();
const clearnetRoomOk = async (room) => {
if (!room || room.isClosed || !CLEARNET_REACH_OK.rooms(room)) return false;
const prefs = await about.visibilityPrefs(room.author).catch(() => null);
return clearnetPublicSync('rooms', room, room.author, prefs, await clearnetDecisions());
};
const clearnetRoomPreview = async (ctx, room) => {
if (!room || !isLoopbackRequest(ctx) || !checkMod(ctx, 'phoneMod')) return false;
const state = await roomsModel.liveState().catch(() => null);
return !!state && state.phase === 'live' && state.rid === room.rid && Array.isArray(state.gridOrder) && state.gridOrder.length > 0;
};
let clearnetRoomsSyncing = false;
const syncClearnetRooms = async () => {
if (clearnetRoomsSyncing || !config.public) return;
clearnetRoomsSyncing = true;
try {
const rooms = await roomsModel.listAll({ filter: 'all', viewerId: getViewerId() }).catch(() => []);
const wanted = [];
for (const room of rooms || []) if (await clearnetRoomOk(room)) wanted.push(room);
const rids = new Set(wanted.map(r => r.rid));
for (const rid of await roomsModel.viewerRooms()) if (!rids.has(rid)) await roomsModel.viewerLeave(rid).catch(() => null);
const inside = new Set(await roomsModel.viewerRooms());
for (const room of wanted) if (!inside.has(room.rid)) await roomsModel.viewerJoin(room).catch(() => null);
} catch (_) {}
finally { clearnetRoomsSyncing = false; }
};
if (config.public) {
setTimeout(syncClearnetRooms, 20000);
const clearnetRoomsTimer = setInterval(syncClearnetRooms, CLEARNET_ROOMS_SYNC_MS);
if (clearnetRoomsTimer.unref) clearnetRoomsTimer.unref();
}
const PUB_INVITES_REFRESH_MS = 24 * 60 * 60 * 1000;
if (ssbConfig.pub === true) {
const announcePubInvites = async () => { try { await pubInvites.announce(await cooler.open()); } catch (_) {} };
setTimeout(announcePubInvites, 60000);
setInterval(announcePubInvites, PUB_INVITES_REFRESH_MS);
}
if (bankingModel.isPubNode()) {
console.log(`[UBI] PUB engine on: paying UBI through ecoind at ${getConfig().wallet.url}`);
setTimeout(() => { runPubEngineTick(); }, 15000);
pubEngineTimer = setInterval(runPubEngineTick, 30 * 60 * 1000);
}
setTimeout(() => { if (getConfig().modules?.larpMod === 'on') larpModel.init().catch(() => {}); }, 10000);
let welcomePmAttempted = false;
async function sendWelcomePmIfFirstLaunch() {
if (welcomePmAttempted) return;
welcomePmAttempted = true;
const flagPath = stateFilePath('oasis-first-contact');
try {
const ssbClient = await cooler.open();
if (!ssbClient || !ssbClient.id) { welcomePmAttempted = false; return; }
const ownId = ssbClient.id;
if (fs.existsSync(flagPath)) {
let prev = '';
try { prev = fs.readFileSync(flagPath, 'utf8'); } catch (_) {}
if (prev.includes(ownId)) return;
try { fs.unlinkSync(flagPath); } catch (_) {}
}
const i18nAll = require('../client/assets/translations/i18n');
const lang = (getConfig() && getConfig().language) || 'en';
const t = i18nAll[lang] || i18nAll.en;
const subject = t.welcomePmSubject || 'Hello.';
const text = t.welcomePmBody || 'Hello.';
try {
await pmModel.sendMessage([], subject, text);
} catch (err) {
console.error('[welcome-pm] publish failed:', err && err.message ? err.message : err);
welcomePmAttempted = false;
return;
}
try {
if (!onboardingModel.begin(ownId)) console.error('[welcome-pm] flag write failed');
} catch (e) {
console.error('[welcome-pm] flag write failed:', e && e.message ? e.message : e);
}
} catch (err) {
console.error('[welcome-pm] unexpected error:', err && err.message ? err.message : err);
welcomePmAttempted = false;
}
}
let firstRunLanguageSettled = false;
const supportedLanguages = () => Object.keys(require('../client/assets/translations/i18n'));
function clearnetLanguage(ctx) {
const supported = supportedLanguages();
const wanted = String(ctx.query.lang || '').trim().toLowerCase();
if (supported.includes(wanted)) {
const store = require('../models/typed_log').requestScope.getStore();
if (store) store.cnLang = wanted;
return wanted;
}
const detected = require('../models/onboarding_model').browserLanguage(ctx.get('accept-language'), supported);
return detected || getConfig().language || 'en';
}
function applyFirstRunLanguage(ctx) {
if (firstRunLanguageSettled || isClearnetPath(ctx.request)) return;
const cfg = getConfig();
const decision = require('../models/onboarding_model').firstRunLanguage({
fresh: !onboardingModel.firstContactSeen(),
isPublic: !!config.public,
configured: cfg.language,
cookie: ctx.cookies.get('language'),
header: ctx.get('accept-language'),
supported: supportedLanguages()
});
if (!decision.settled) return;
firstRunLanguageSettled = true;
if (decision.language && decision.language !== cfg.language) {
cfg.language = decision.language;
saveConfig(cfg);
}
setTimeout(welcomePmTick, 0);
}
let welcomePmRetries = 0;
const welcomePmTick = async () => {
if (onboardingModel.firstContactSeen()) return;
if (!firstRunLanguageSettled) {
const cfg = getConfig();
const decision = require('../models/onboarding_model').firstRunLanguage({ fresh: true, isPublic: !!config.public, configured: cfg.language, supported: supportedLanguages() });
if (!decision.settled) return;
firstRunLanguageSettled = true;
}
await sendWelcomePmIfFirstLaunch();
if (onboardingModel.firstContactSeen()) return;
if (welcomePmRetries++ >= 30) return;
setTimeout(welcomePmTick, 4000);
};
setTimeout(welcomePmTick, 3000);
const pbFmtDate = (iso) => iso ? moment(iso).format('YYYY-MM-DD HH:mm:ss') : '';
const pbFmtRemaining = (endIso) => {
if (!endIso) return '';
const ms = moment(endIso).diff(moment());
if (ms <= 0) return '0d 00:00:00';
const d = Math.floor(ms / 86400000);
const h = Math.floor((ms % 86400000) / 3600000);
const mi = Math.floor((ms % 3600000) / 60000);
const se = Math.floor((ms % 60000) / 1000);
const pad = n => String(n).padStart(2, '0');
return `${d}d ${pad(h)}:${pad(mi)}:${pad(se)}`;
};
const pbFill = (tpl, vars) => String(tpl || '').replace(/\{(\w+)\}/g, (m, k) => (k in vars ? vars[k] : m));
const pbTitleCase = (s) => { const t = String(s || ''); return t ? t.charAt(0).toUpperCase() + t.slice(1).toLowerCase() : ''; };
const pbAtId = (id) => id ? '@' + String(id).replace(/^@/, '') : '';
function buildLarpRulingMsg(data = {}, t = {}) {
const house = data.house || {};
const name = house.name || data.houseKey || '';
const intro = pbFill(t.politicalBotLarpIntro || 'Now {house} rules during this cycle: {cycle}', {
house: `[${name}](/larp/${encodeURIComponent(data.houseKey || '')})`,
cycle: `[${data.cycleFormatted || ''}](/larp)`
});
const blocks = [
[intro],
[name, house.description || ''],
[
`${t.larpRolesLabel || 'Roles'}: ${house.roles || ''}`,
`${t.larpFunctionLabel || 'Function'}: ${house.function || ''}`,
`${t.larpMonthLabel || 'Governance cycle'}: ${house.month != null ? house.month : ''}`,
`${t.larpMembersCount || 'Members'}: ${data.memberCount || 0}`
],
[house.motto ? `โ${house.motto}โ` : '']
];
const body = blocks
.map(block => block.filter(l => l != null && String(l).trim() !== '').join('\n'))
.filter(Boolean)
.join('\n\n');
return { subject: 'LARP_RULING', body };
}
function pbGovBlock(gov = {}, t = {}) {
const lines = [`${t.parliamentGovernmentCard || 'Current Government'}: ${String(gov.method || '').toUpperCase()}`];
if (gov.leaderId) lines.push(`${t.politicalBotLeaderLabel || 'Leader'}: ${pbAtId(gov.leaderId)}`);
lines.push(`${t.parliamentLegSince || 'CYCLE SINCE'}: ${pbFmtDate(gov.since)}`);
lines.push(`${t.parliamentLegEnd || 'CYCLE END'}: ${pbFmtDate(gov.end)}`);
lines.push(`${t.parliamentTimeRemaining || 'Time remaining'}: ${pbFmtRemaining(gov.end)}`);
lines.push(`${t.parliamentPopulation || 'Population'}: ${gov.population || 0}`);
return lines;
}
function buildParliamentGovMsg(gov = {}, t = {}) {
const govLink = `[${pbTitleCase(gov.method)}](/parliament)`;
const intro = gov.leaderId
? pbFill(t.politicalBotParliamentIntroLeader || 'The current government is {gov}, led by {leader}', { gov: govLink, leader: pbAtId(gov.leaderId) })
: pbFill(t.politicalBotParliamentIntro || 'The current government is {gov}', { gov: govLink });
return { subject: 'PARLIAMENT_GOV', body: [intro, ''].concat(pbGovBlock(gov, t)).join('\n') };
}
function buildTribeGovMsg(gov = {}, t = {}) {
const govLink = `[${pbTitleCase(gov.method)}](/tribe/${encodeURIComponent(gov.tribeId || '')}?section=governance)`;
const vars = { tribe: gov.tribeName || '', gov: govLink, leader: pbAtId(gov.leaderId) };
const intro = gov.leaderId
? pbFill(t.politicalBotTribeIntroLeader || 'The current government in the Tribe {tribe} is {gov}, led by {leader}', vars)
: pbFill(t.politicalBotTribeIntro || 'The current government in the Tribe {tribe} is {gov}', vars);
return { subject: 'TRIBE_GOV', body: [intro, ''].concat(pbGovBlock(gov, t)).join('\n') };
}
let jobsBotRunning = false;
async function checkJobMatches() {
if (jobsBotRunning) return;
jobsBotRunning = true;
try {
const viewerId = getViewerId();
if (!viewerId) return;
const cv = await cvModel.getCVByUserId().catch(() => null);
if (!cv || cv.aiManaged === false) return;
const threshold = Math.min(100, Math.max(0, Number(cv.matchThreshold) || 80)) / 100;
const matches = await dataModel.jobMatchesFor(viewerId, { minScore: threshold }).catch(() => []);
if (!matches.length) return;
const announced = await pmModel.sentRefs().catch(() => new Set());
const i18nAll = require('../client/assets/translations/i18n');
const lang = (getConfig() && getConfig().language) || 'en';
const t = i18nAll[lang] || i18nAll.en;
for (const m of matches.slice(0, 5)) {
const ref = `${m.id}|${Math.round(m.score * 100)}`;
if (announced.has(`JOB_MATCH|${ref}`)) continue;
const pctText = `${Math.round(m.score * 100)}%`;
const body = [
(t.jobsBotMatchIntro || 'A job matches your curriculum.'),
'',
`${t.jobsBotMatchJob || 'Job'}: [${m.title || m.id}](${m.href})`,
`${t.matchScore || 'Match'}: ${pctText}`,
m.common.length ? `${t.commonSkills || 'Common skills'}: ${m.common.slice(0, 10).join(', ')}` : '',
'',
(t.jobsBotMatchHint || 'You receive this because your curriculum is AI managed. You can turn it off in your CV.')
].filter(Boolean).join('\n');
try {
await notifyBot('JOB_MATCH', [], body, false, ref);
announced.add(`JOB_MATCH|${ref}`);
} catch (e) {
if (config.debug) console.error('[jobs-bot] send failed:', e && e.message);
}
}
} catch (e) {
if (config.debug) console.error('[jobs-bot] failed:', e && e.message);
} finally {
jobsBotRunning = false;
}
}
let politicalCheckRunning = false;
async function checkPoliticalChanges() {
if (politicalCheckRunning) return;
politicalCheckRunning = true;
try {
const ssbClient = await cooler.open().catch(() => null);
if (!ssbClient || !ssbClient.id) return;
const ownId = ssbClient.id;
const i18nAll = require('../client/assets/translations/i18n');
const lang = (getConfig() && getConfig().language) || 'en';
const t = i18nAll[lang] || i18nAll.en;
const announced = await pmModel.sentRefs().catch(() => new Set());
const alreadyAnnounced = (subject, ref) => announced.has(`${subject}|${ref}`);
const seen = pmPolicy.readAnnounceSeen();
const send = async (subject, body, ref) => {
const { send: shouldSend, remember } = pmPolicy.decideAnnouncement({ subject, ref, announced, seen });
if (!shouldSend) {
if (remember) { seen[subject] = String(ref); pmPolicy.writeAnnounceSeen(seen); }
return;
}
try {
await notifyBot(subject, [], body, { ref });
announced.add(`${subject}|${ref}`);
seen[subject] = String(ref);
pmPolicy.writeAnnounceSeen(seen);
} catch (e) { console.error('[political-bot] send failed:', e && e.message); }
};
try {
const houseKey = larpModel.getGoverningHouseKey();
if (houseKey) {
let cycleFormatted = '';
try { cycleFormatted = larpModel.computeCycle().formatted; } catch (_) {}
const ref = larpModel.getGoverningPeriodId();
if (!alreadyAnnounced('LARP_RULING', ref)) {
const house = (larpModel.getHouse && larpModel.getHouse(houseKey)) || {};
let memberCount = 0;
try { const hc = await larpModel.listHousesWithCounts(); const h = (hc || []).find(x => x.key === houseKey); memberCount = h ? (h.memberCount || 0) : 0; } catch (_) {}
const { subject, body } = buildLarpRulingMsg({ houseKey, house, memberCount, cycleFormatted }, t);
await send(subject, body, ref);
}
}
} catch (e) { console.error('[political-bot] larp:', e && e.message); }
try {
const card = await parliamentModel.getLatestGovernmentCard().catch(() => null);
if (card) {
const ref = [String(card.id || card.method || ''), String(card.since || '')].filter(Boolean).join(':');
if (ref && !alreadyAnnounced('PARLIAMENT_GOV', ref)) {
let population = 0;
try { population = Number(await inhabitantsModel.countInhabitants()) || 0; } catch (_) {}
const leaderId = (card.powerType === 'inhabitant' && card.powerId) ? card.powerId : null;
const { subject, body } = buildParliamentGovMsg({ method: card.method, since: card.since, end: card.end, population, leaderId }, t);
await send(subject, body, ref);
}
}
} catch (e) { console.error('[political-bot] parliament:', e && e.message); }
try {
let tribes = [];
try { tribes = await tribesModel.listAll(); } catch (_) { tribes = []; }
for (const tribe of (tribes || [])) {
if (!tribe || !tribe.id) continue;
if (!Array.isArray(tribe.members) || !tribe.members.includes(ownId)) continue;
if (tribe.parentTribeId) continue;
let term = null;
try { term = await parliamentModel.tribe.getCurrentTerm(tribe.id); } catch (_) { term = null; }
if (!term) continue;
const ref = `${tribe.id}:${String(term.id || term.startAt || term.method || '')}`;
if (!alreadyAnnounced('TRIBE_GOV', ref)) {
const lead = Array.isArray(term.leaders) && term.leaders.length ? term.leaders[0] : null;
const leaderId = lead && typeof lead === 'object' ? (lead.id || lead.powerId || null) : lead;
const population = Array.isArray(tribe.members) ? tribe.members.length : 0;
const { subject, body } = buildTribeGovMsg({ method: term.method, since: term.startAt, end: term.endAt, population, leaderId, tribeId: tribe.id, tribeName: tribe.title || tribe.name || tribe.id }, t);
await send(subject, body, ref);
}
}
} catch (e) { console.error('[political-bot] tribe:', e && e.message); }
} catch (err) {
console.error('[political-bot] unexpected:', err && err.message);
} finally {
politicalCheckRunning = false;
}
}
app._close = () => {
if (pubEngineTimer) clearInterval(pubEngineTimer);
nameWarmup.close();
cooler.close();
};
module.exports = app;
if (config.open === true && process.env.OASIS_MOBILE !== '1') open(url);