'); 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);