OASIS_MOBILE/src/models/karma_model.js
oasis-bot 948b6460d9 upstream: integrar Oasis 1.3.3
Diferencia 1.3.1 -> 1.3.3 del codigo de la APK oficial aplicada a tres
bandas encima de la rama: karma en el perfil, salas con directo, fotos
selladas, visibilidad de menciones, telefono por stream en Android y
las traducciones nuevas. Sin dependencias nuevas.

OasisMobile.css sigue siendo el del fork; del oficial solo se traen al
final las reglas de las piezas nuevas (karma, directo a pantalla
completa, salas en vivo, graphos).
2026-10-19 00:30:25 +02:00

591 lines
24 KiB
JavaScript

const pull = require("../server/node_modules/pull-stream");
const { readTyped, CONTENT_TYPES } = require("./typed_log");
const { getConfig } = require("../configs/config-manager.js");
const { config } = require("../server/SSB_server.js");
function getLogLimit() {
return getConfig().ssbLogStream?.limit || 1000;
}
const OWN_SCORE_CAP = 200;
const RECEIVED_AUTHOR_MIN_AGE_MS = 30 * 24 * 60 * 60 * 1000;
const opinionCategories = require("../backend/opinion_categories");
const CONSTRUCTIVE_OPINIONS = new Set(opinionCategories.karma.constructive);
const MODERATION_OPINIONS = new Set(opinionCategories.karma.moderation);
const NEUTRAL_OPINIONS = new Set(opinionCategories.karma.neutral);
const NEGATIVE_MIN_VOTERS = 3;
const KARMA_PERIOD_MS = 30 * 24 * 60 * 60 * 1000;
const NEGATIVE_PATTERN_MIN_VOTERS = 5;
const NEGATIVE_PATTERN_POINTS = 2;
const POSITIVE_PER_VOTER_CAP = 10;
const TRUST_MIN_FOLLOWERS = 2;
const REACTIONS_PER_MESSAGE_CAP = 10;
const PLAYS_PER_EPISODE_CAP = 20;
const ACTIONS_CACHE_TTL_MS = 60 * 1000;
function resolveUserId(maybeId) {
const s = String(maybeId || "").trim();
if (s) return s;
return config?.keys?.id || "";
}
function idsEqual(a, b) {
if (!a || !b) return false;
const A = String(a).trim();
const B = String(b).trim();
if (A === B) return true;
const strip = s => s.replace(/^@/, "").replace(/\.ed25519$/, "");
return strip(A) === strip(B);
}
function inferType(c = {}) {
if (c.vote) return "vote";
if (c.votes) return "votes";
if (c.address && c.coin === "ECO" && c.type === "wallet") return "bankWallet";
if (c.type === "ubiClaimResult" && c.txid && c.epochId) return "ubiClaimResult";
if (typeof c.amount !== "undefined" && c.epochId && c.allocationId) return "bankClaim";
if (typeof c.item_type !== "undefined" && typeof c.status !== "undefined") return "market";
if (typeof c.goal !== "undefined" && typeof c.progress !== "undefined") return "project";
if (typeof c.members !== "undefined" && typeof c.isAnonymous !== "undefined") return "tribe";
if (typeof c.date !== "undefined" && typeof c.location !== "undefined") return "event";
if (typeof c.priority !== "undefined" && typeof c.status !== "undefined" && c.title) return "task";
if (typeof c.confirmations !== "undefined" && typeof c.severity !== "undefined") return "report";
if (typeof c.job_type !== "undefined" && typeof c.status !== "undefined") return "job";
if (typeof c.url !== "undefined" && typeof c.mimeType !== "undefined" && c.type === "audio") return "audio";
if (typeof c.url !== "undefined" && typeof c.mimeType !== "undefined" && c.type === "video") return "video";
if (typeof c.url !== "undefined" && c.title && c.key) return "document";
if (typeof c.text !== "undefined" && typeof c.refeeds !== "undefined") return "feed";
if (typeof c.text !== "undefined" && typeof c.contentWarning !== "undefined") return "post";
if (typeof c.contact !== "undefined") return "contact";
if (typeof c.about !== "undefined") return "about";
if (typeof c.concept !== "undefined" && typeof c.amount !== "undefined" && c.status) return "transfer";
return "";
}
function normalizeType(a) {
const t = a.type || a.content?.type || inferType(a.content) || "";
return String(t).toLowerCase();
}
function priorityBump(p) {
const s = String(p || "").toUpperCase();
if (s === "HIGH") return 3;
if (s === "MEDIUM") return 1;
return 0;
}
function severityBump(s) {
const x = String(s || "").toUpperCase();
if (x === "CRITICAL") return 6;
if (x === "HIGH") return 4;
if (x === "MEDIUM") return 2;
return 0;
}
function scoreMarket(c) {
const st = String(c.status || "").toUpperCase();
let s = 5;
if (st === "SOLD") s += 8;
else if (st === "ACTIVE") s += 3;
const bids = Array.isArray(c.auctions_poll) ? c.auctions_poll.length : 0;
s += Math.min(10, bids);
return s;
}
function scoreProject(c) {
const st = String(c.status || "ACTIVE").toUpperCase();
const prog = Number(c.progress || 0);
let s = 8 + Math.min(10, prog / 10);
if (st === "FUNDED") s += 10;
return s;
}
function calculateOpinionScore(content) {
const cats = content?.opinions || {};
let s = 0;
for (const k in cats) {
if (!Object.prototype.hasOwnProperty.call(cats, k)) continue;
if (NEUTRAL_OPINIONS.has(k)) continue;
if (k === "interesting" || k === "inspiring") s += 5;
else if (MODERATION_OPINIONS.has(k)) s -= 5;
else if (CONSTRUCTIVE_OPINIONS.has(k)) s -= 3;
else s += 1;
}
return s;
}
function basePointsFor(action) {
const t = normalizeType(action);
const c = action.content || {};
const rawType = String(c.type || "").toLowerCase();
if (t === "post") return 10;
else if (t === "comment") return 5;
else if (t === "like") return 2;
else if (t === "image") return 8;
else if (t === "video") return 12;
else if (t === "audio") return 8;
else if (t === "document") return 6;
else if (t === "bookmark") return 2;
else if (t === "feed") return 6;
else if (t === "forum") return (c.root ? 5 : 10);
else if (t === "vote") return (3 + calculateOpinionScore(c));
else if (t === "votes") return Math.min(10, Number(c.totalVotes || 0));
else if (t === "market") return scoreMarket(c);
else if (t === "project") return scoreProject(c);
else if (t === "tribe") return (6 + Math.min(10, Array.isArray(c.members) ? c.members.length * 0.5 : 0));
else if (t === "event") return (4 + Math.min(10, Array.isArray(c.attendees) ? c.attendees.length : 0));
else if (t === "task") return (3 + priorityBump(c.priority));
else if (t === "report") return (4 + (Array.isArray(c.confirmations) ? c.confirmations.length : 0) + severityBump(c.severity));
else if (t === "curriculum") return 5;
else if (t === "wikipage") return (c.replaces ? 2 : 6);
else if (t === "emergency") return (c.replaces ? 1 : 5);
else if (t === "emergencyconfirm") return 1;
else if (t === "emergencyupdate") return 2;
else if (t === "mailinglist") return (c.replaces ? 1 : 3);
else if (t === "logisticsroute") return (c.replaces ? 1 : 4);
else if (t === "logisticsrating") return 1;
else if (t === "podcast") return (c.replaces ? 1 : 5);
else if (t === "podcastepisode") return (c.replaces ? 1 : 4);
else if (t === "podcastopinion" || t === "podcastplay" || t === "campaignopinion" || t === "logisticsopinion") return 1;
else if (t === "campaign") return (c.replaces ? 1 : 5);
else if (t === "campaignsignature") return 1;
else if (t === "campaignupdate") return 2;
else if (t === "aiexchange") return (Array.isArray(c.ctx) ? Math.min(10, c.ctx.length) : 0);
else if (t === "job") return (4 + (Array.isArray(c.subscribers) ? c.subscribers.length : 0));
else if (t === "transfer") return 1;
else if (t === "about") return (1 + ((c.visibilityPrefs && c.visibilityPrefs.fediverseHandle) ? 8 : 0));
else if (t === "contact") return 1;
else if (t === "pub") return 1;
else if (t === "parliamentcandidature" || rawType === "parliamentcandidature") return 12;
else if (t === "parliamentterm" || rawType === "parliamentterm") return 25;
else if (t === "parliamentproposal" || rawType === "parliamentproposal") return 8;
else if (t === "parliamentlaw" || rawType === "parliamentlaw") return 16;
else if (t === "parliamentrevocation" || rawType === "parliamentrevocation") return 10;
else if (t === "courts_case" || t === "courtscase" || rawType === "courts_case") return 4;
else if (t === "courts_evidence" || t === "courtsevidence" || rawType === "courts_evidence") return 3;
else if (t === "courts_answer" || t === "courtsanswer" || rawType === "courts_answer") return 4;
else if (t === "courts_verdict" || t === "courtsverdict" || rawType === "courts_verdict") return 10;
else if (t === "courts_settlement" || t === "courtssettlement" || rawType === "courts_settlement") return 8;
else if (t === "courts_nomination" || t === "courtsnomination" || rawType === "courts_nomination") return 6;
else if (t === "courts_nom_vote" || t === "courtsnomvote" || rawType === "courts_nom_vote") return 3;
else if (t === "courts_public_pref" || t === "courtspublicpref" || rawType === "courts_public_pref") return 1;
else if (t === "courts_mediators" || t === "courtsmediators" || rawType === "courts_mediators") return 6;
else if (t === "courts_open_support" || t === "courtsopensupport" || rawType === "courts_open_support") return 2;
else if (t === "courts_verdict_vote" || t === "courtsverdictvote" || rawType === "courts_verdict_vote") return 3;
else if (t === "courts_judge_assign" || t === "courtsjudgeassign" || rawType === "courts_judge_assign") return 5;
else if (t === "larpjoinhouse") return 15;
else if (t === "larphousepost") return 6;
else if (t === "larptestattempt") return 3;
else if (t === "torrent") return 6;
else if (t === "file") return 6;
else if (t === "shop" || t === "shopproduct") return 6;
else if (t === "shop-purchase") return 2;
else if (t === "pad" || t === "padentry") return 3;
else if (t === "room") return 3;
else if (t === "roommember") return 1;
else if (t === "calendar" || t === "calendarnote" || t === "calendardate") return 3;
else if (t === "chat") return 1;
else if (t === "gamescore") return 2;
else if (t === "pixelia") return 2;
else if (rawType === "industry") return 8;
else if (rawType === "industryblueprint") return 6;
else if (rawType === "industrybuild") return 8;
else if (rawType === "industrycontribution") return 4;
else if (rawType === "industryallocation") return 10;
else if (rawType === "industryvote") return 2;
else if (rawType === "industrymember") return 2;
else if (rawType === "industryopinion") return 1;
else if (rawType === "schoolcourse") return 10;
else if (rawType === "schoollesson") return 6;
else if (rawType === "schoolenroll") return 3;
else if (rawType === "schoolcertificate") return 12;
else if (rawType === "schoolexam") return 6;
else if (rawType === "schoolprogress") return 1;
else if (rawType === "schoolopinion") return 2;
return 0;
}
function decayFor(ts, nowMs) {
const ageDays = ts ? (nowMs - ts) / 86400000 : Infinity;
return ageDays <= 30 ? 1.0 : ageDays <= 90 ? 0.5 : 0.25;
}
function scoreFromActions(actions) {
let score = 0;
const nowMs = Date.now();
const perTypeDay = new Map();
for (const action of actions) {
const pts = basePointsFor(action);
if (!pts) continue;
const ts = action.value?.timestamp;
const dayKey = `${normalizeType(action)}:${ts ? new Date(ts).toISOString().slice(0, 10) : "?"}`;
const n = (perTypeDay.get(dayKey) || 0) + 1;
perTypeDay.set(dayKey, n);
score += pts * decayFor(ts, nowMs) / n;
}
return Math.max(0, Math.round(Math.min(OWN_SCORE_CAP, score)));
}
function receivedBreakdownFor(userId, actions) {
const nowMs = Date.now();
const mine = new Set();
const firstTs = new Map();
const follows = new Map();
for (const a of actions) {
if (!a.author) continue;
const ts = Number(a.receivedAt) || 0;
if (ts && (!firstTs.has(a.author) || ts < firstTs.get(a.author))) firstTs.set(a.author, ts);
if (a.author === userId && a.id) mine.add(a.id);
const c = a.content || {};
if (c.type === "contact" && typeof c.contact === "string" && c.contact !== a.author) {
const k = `${a.author}|${c.contact}`;
const at = Number(a.value?.timestamp) || ts;
const prev = follows.get(k);
if (!prev || at >= prev.at) follows.set(k, { on: c.following === true && c.blocking !== true, at });
}
}
const followersOf = new Map();
for (const [k, f] of follows) {
if (!f.on) continue;
const [from, to] = k.split("|");
if (!followersOf.has(to)) followersOf.set(to, new Set());
followersOf.get(to).add(from);
}
const known = (id) => firstTs.has(id) && nowMs - firstTs.get(id) >= RECEIVED_AUTHOR_MIN_AGE_MS;
const trusted = (voter, sameSide) => {
let n = 0;
for (const f of followersOf.get(voter) || []) {
if (f === userId || sameSide.has(f) || !known(f)) continue;
if (++n >= TRUST_MIN_FOLLOWERS) return true;
}
return false;
};
const seen = new Set();
const perTarget = new Map();
const negatives = new Map();
const votes = new Map();
const reactions = new Map();
const credits = [];
let positive = 0;
let negative = 0;
const add = (author, kind, target, pts, ts, capPerTarget = Infinity) => {
const k = `${kind}:${author}:${target}`;
if (seen.has(k)) return;
const n = perTarget.get(`${kind}:${target}`) || 0;
if (n >= capPerTarget) return;
seen.add(k);
perTarget.set(`${kind}:${target}`, n + 1);
credits.push({ voter: author, pts, ts: Number(ts) || 0 });
};
const addNegative = (author, target, pts, ts) => {
const k = `neg:${author}:${target}`;
if (seen.has(k)) return;
seen.add(k);
if (!negatives.has(target)) negatives.set(target, []);
negatives.get(target).push({ voter: author, pts, ts: Number(ts) || 0 });
};
for (const a of actions) {
const b = a.author;
if (!b || b === userId) continue;
if (!firstTs.has(b) || nowMs - firstTs.get(b) < RECEIVED_AUTHOR_MIN_AGE_MS) continue;
const c = a.content || {};
const type = String(c.type || "");
const ts = a.value?.timestamp;
if (/Opinion$/.test(type) && type !== "pollOpinion" && mine.has(c.target)) {
const cat = String(c.category || "");
if (NEUTRAL_OPINIONS.has(cat)) continue;
if (MODERATION_OPINIONS.has(cat)) addNegative(b, c.target, -5, ts);
else if (CONSTRUCTIVE_OPINIONS.has(cat)) addNegative(b, c.target, -3, ts);
else add(b, "opinion", c.target, 3, ts);
}
else if (type === "vote" && c.vote && mine.has(c.vote.link)) {
const k = `${b}:${c.vote.link}`;
const prev = votes.get(k);
if (!prev || (Number(ts) || 0) >= prev.ts) votes.set(k, { author: b, target: c.vote.link, value: Number(c.vote.value) || 0, ts: Number(ts) || 0 });
}
else if (type === "feed-action" && c.action === "vote" && mine.has(c.root || c.target)) add(b, "opinion", c.root || c.target, 3, ts);
else if (type === "feed-action" && c.action === "comment" && mine.has(c.root)) add(b, "comment", c.root, 2, ts, 10);
else if (type === "feed-action" && c.action === "refeed" && mine.has(c.root)) add(b, "refeed", c.root, 2, ts, 10);
else if (type === "post" && c.root && mine.has(c.root)) add(b, "comment", c.root, 2, ts, 10);
else if (type === "contact" && c.following === true && c.contact === userId) add(b, "follow", userId, 2, ts);
else if (type === "chatReaction" && mine.has(c.target)) {
const k = `${b}:${c.target}:${c.emoji}`;
const prev = reactions.get(k);
if (!prev || (Number(ts) || 0) >= prev.ts) reactions.set(k, { author: b, target: c.target, emoji: c.emoji, on: c.on !== false, ts: Number(ts) || 0 });
}
else if (type === "podcastPlay" && mine.has(c.target)) add(b, "play", c.target, 1, ts, PLAYS_PER_EPISODE_CAP);
else if (type === "marketPurchase" && mine.has(c.target)) add(b, "purchase", c.target, 5, ts);
else if (type === "shop-purchase" && c.seller === userId) add(b, "purchase", c.productId || a.id, 5, ts);
else if (type === "schoolEnroll" && c.value === true && mine.has(c.courseId)) add(b, "enroll", c.courseId, 5, ts);
else if (type === "transferConfirm" && mine.has(c.target)) add(b, "confirm", c.target, 3, ts);
}
for (const r of reactions.values()) {
if (!r.on) continue;
if (r.emoji === "down") addNegative(r.author, r.target, -3, r.ts);
else add(r.author, "reaction", r.target, 1, r.ts, REACTIONS_PER_MESSAGE_CAP);
}
for (const v of votes.values()) {
if (v.value > 0) add(v.author, "opinion", v.target, 3, v.ts);
else if (v.value < 0) addNegative(v.author, v.target, -3, v.ts);
}
const vouched = new Map();
const isTrusted = (voter, side, cache) => {
if (!cache.has(voter)) cache.set(voter, trusted(voter, side));
return cache.get(voter);
};
const usedByVoter = new Map();
for (const c of credits.sort((x, y) => x.ts - y.ts)) {
if (!isTrusted(c.voter, new Set(), vouched)) continue;
const k = `${c.voter}:${Math.floor(c.ts / KARMA_PERIOD_MS)}`;
const used = usedByVoter.get(k) || 0;
const take = Math.min(c.pts, POSITIVE_PER_VOTER_CAP - used);
if (take <= 0) continue;
usedByVoter.set(k, used + take);
positive += take * decayFor(c.ts, nowMs);
}
const blaming = new Set();
for (const list of negatives.values()) for (const n of list) blaming.add(n.voter);
const vouchedAgainst = new Map();
const pattern = new Map();
for (const all of negatives.values()) {
const list = all.filter(n => isTrusted(n.voter, blaming, vouchedAgainst));
if (list.length >= NEGATIVE_MIN_VOTERS) {
for (const n of list) negative += Math.abs(n.pts) * decayFor(n.ts, nowMs);
continue;
}
for (const n of list) {
if (!n.ts || nowMs - n.ts > KARMA_PERIOD_MS) continue;
if ((pattern.get(n.voter) || 0) < n.ts) pattern.set(n.voter, n.ts);
}
}
if (pattern.size >= NEGATIVE_PATTERN_MIN_VOTERS) {
for (const ts of pattern.values()) negative += NEGATIVE_PATTERN_POINTS * decayFor(ts, nowMs);
}
positive = Math.round(positive);
negative = Math.round(negative);
return { score: positive - negative, positive, negative };
}
function receivedScoreFor(userId, actions) {
return receivedBreakdownFor(userId, actions).score;
}
module.exports = ({ services, ranges } = {}) => {
const normalizeRange = ranges && ranges.normalizeRange ? ranges.normalizeRange : (r) => String(r || "all");
const widenRange = ranges && ranges.widenRange ? ranges.widenRange : (arr, r) => normalizeRange(r);
const filterByRange = ranges && ranges.filterByRange ? ranges.filterByRange : (arr) => arr;
let ssbInstance;
async function openSsb() {
if (ssbInstance) return ssbInstance;
if (services?.cooler?.open) ssbInstance = await services.cooler.open();
else if (global.ssb) ssbInstance = global.ssb;
else {
try {
const srv = require("../server/SSB_server.js");
ssbInstance = srv?.ssb || srv?.server || srv?.default || null;
} catch (_) {
ssbInstance = null;
}
}
return ssbInstance;
}
let actionsCache = null;
async function listAllActions() {
const ssb = await openSsb();
if (!ssb) {
return [];
}
const ownSeq = await ownFeedSeq(ssb);
if (actionsCache && actionsCache.ownSeq === ownSeq && Date.now() - actionsCache.ts < ACTIONS_CACHE_TTL_MS) return actionsCache.value;
const msgs = await readTyped(ssb, CONTENT_TYPES, { limit: Math.max(getLogLimit(), 20000) });
const value = msgs.map(m => {
const v = m.value || {};
const c = v.content || {};
return {
id: v.key || m.key,
author: v.author,
type: (c.type || "").toLowerCase(),
value: v,
content: c,
receivedAt: Number(m.timestamp) || 0
};
});
actionsCache = { ts: Date.now(), ownSeq, value };
return value;
}
async function ownFeedSeq(ssb) {
return new Promise((res) => {
try {
pull(
ssb.createUserStream({ id: ssb.id, reverse: true, limit: 1 }),
pull.collect((err, msgs) => res(err || !msgs || !msgs.length ? 0 : (msgs[0].value && msgs[0].value.sequence) || 0))
);
} catch (_) { res(0); }
});
}
async function publishKarmaScore(userId, karmaScore) {
const ssb = await openSsb();
if (!ssb || !ssb.publish) return false;
if (!(await ownFeedSeq(ssb))) return false;
const timestamp = new Date().toISOString();
const content = { type: "karmaScore", karmaScore, userId, timestamp };
return new Promise((resolve, reject) => {
ssb.publish(content, (err, msg) => err ? reject(err) : resolve(msg));
});
}
async function fetchUserActions(userId) {
const me = resolveUserId(userId);
const actions = await listAllActions();
const authored = actions.filter(a =>
(a.author && a.author === me) || (a.value?.author && a.value.author === me)
);
if (authored.length) return authored;
return actions.filter(a => {
const c = a.content || {};
const fields = [c.author, c.organizer, c.seller, c.about, c.contact];
return fields.some(f => f && f === me);
});
}
async function getCarbonGramsForUser(userId) {
const ssb = await openSsb();
if (!ssb || !userId) return 0;
return new Promise((resolve) => {
let bytes = 0;
pull(
ssb.createUserStream({ id: userId }),
pull.drain(
(m) => { try { bytes += Buffer.byteLength(JSON.stringify(m && m.value), 'utf8'); } catch (_) {} },
() => resolve((bytes / (1024 * 1024)) * 0.095)
)
);
});
}
async function getUserEngagementScore(userId) {
const ssb = await openSsb();
const uid = resolveUserId(userId);
const actions = await fetchUserActions(uid);
const all = await listAllActions();
const rawKarma = scoreFromActions(actions) + receivedScoreFor(uid, all);
const carbonGrams = await getCarbonGramsForUser(uid).catch(() => 0);
const karmaScore = Math.max(0, Math.round(rawKarma - carbonGrams));
const prev = await getLastKarmaScore(uid);
const lastPublishedTimestamp = await getLastPublishedTimestamp(uid);
const isSelf = idsEqual(uid, ssb.id);
const hasSSB = !!(ssb && ssb.publish);
const changed = (prev === null) || (karmaScore !== prev);
const nowMs = Date.now();
const lastMs = lastPublishedTimestamp ? new Date(lastPublishedTimestamp).getTime() : 0;
const cooldownOk = (nowMs - lastMs) >= 24 * 60 * 60 * 1000;
if (isSelf && hasSSB && changed && cooldownOk) {
await publishKarmaScore(uid, karmaScore);
}
return karmaScore;
}
async function getLastKarmaScore(userId) {
const ssb = await openSsb();
if (!ssb) return null;
return new Promise((resolve) => {
const source = ssb.messagesByType
? ssb.messagesByType({ type: "karmaScore", reverse: true })
: ssb.createLogStream && ssb.createLogStream({ reverse: true });
if (!source) return resolve(null);
pull(
source,
pull.filter(msg => {
const v = msg.value || msg;
const c = v.content || {};
return c && c.type === "karmaScore" && c.userId === userId;
}),
pull.take(1),
pull.collect((err, arr) => {
if (err || !arr || !arr.length) return resolve(null);
const v = arr[0].value || arr[0];
const c = v.content || {};
resolve(Number(c.karmaScore) || 0);
})
);
});
}
async function getLastPublishedTimestamp(userId) {
const ssb = await openSsb();
if (!ssb) return new Date(0).toISOString();
const fallback = new Date(0).toISOString();
return new Promise((resolve) => {
const source = ssb.messagesByType
? ssb.messagesByType({ type: "karmaScore", reverse: true })
: ssb.createLogStream && ssb.createLogStream({ reverse: true });
if (!source) return resolve(fallback);
pull(
source,
pull.filter(msg => {
const v = msg.value || msg;
const c = v.content || {};
return c && c.type === "karmaScore" && c.userId === userId;
}),
pull.take(1),
pull.collect((err, arr) => {
if (err || !arr || !arr.length) return resolve(fallback);
const v = arr[0].value || arr[0];
const c = v.content || {};
resolve(c.timestamp || fallback);
})
);
});
}
async function getKarmaBreakdown(userId) {
const uid = resolveUserId(userId);
const received = receivedBreakdownFor(uid, await listAllActions());
return { karmaScore: await getUserEngagementScore(uid), positive: received.positive, negative: received.negative };
}
async function listKarmaHistory(userId, range = "all", explicit = true) {
const ssb = await openSsb();
if (!ssb) return { points: [], hasAny: false, range: normalizeRange(range) };
const uid = resolveUserId(userId);
const msgs = await readTyped(ssb, ["karmaScore"], { limit: Math.max(getLogLimit(), 5000) });
const all = msgs
.filter(m => m.value && m.value.author === uid && m.value.content && m.value.content.type === "karmaScore")
.map(m => ({ ts: Date.parse(m.value.content.timestamp || "") || Number(m.value.timestamp) || 0, score: Number(m.value.content.karmaScore) || 0 }))
.filter(x => x.ts > 0)
.sort((a, b) => a.ts - b.ts);
const effective = explicit ? normalizeRange(range) : widenRange(all, range);
return { points: filterByRange(all, effective).slice(-120), hasAny: all.length >= 2, range: effective };
}
return {
listAllActions,
fetchUserActions,
scoreFromActions,
receivedBreakdownFor,
receivedScoreFor,
getCarbonGramsForUser,
getUserEngagementScore,
getKarmaBreakdown,
getLastKarmaScore,
getLastPublishedTimestamp,
publishKarmaScore,
listKarmaHistory,
basePointsFor,
decayFor,
NEGATIVE_MIN_VOTERS,
NEGATIVE_PATTERN_MIN_VOTERS,
NEGATIVE_PATTERN_POINTS,
POSITIVE_PER_VOTER_CAP,
KARMA_PERIOD_MS,
TRUST_MIN_FOLLOWERS
};
};