Files
broccolini-bot/services/patternStore.js
2026-04-20 18:05:36 +00:00

287 lines
8.0 KiB
JavaScript

/**
* In-memory counter store with TTL windows for pattern detection.
* Windows: 'today' resets at midnight, 'week' resets Monday 00:00, 'month' resets 1st 00:00.
*/
// store[window][namespace][key] = count
const store = {
today: new Map(),
week: new Map(),
month: new Map()
};
function getNamespaceMap(window, namespace) {
const windowMap = store[window];
if (!windowMap) return null;
if (!windowMap.has(namespace)) windowMap.set(namespace, new Map());
return windowMap.get(namespace);
}
function increment(namespace, key, window) {
const map = getNamespaceMap(window, namespace);
if (!map) return;
map.set(key, (map.get(key) || 0) + 1);
}
function get(namespace, key, window) {
const map = getNamespaceMap(window, namespace);
if (!map) return 0;
return map.get(key) || 0;
}
function reset(namespace, window) {
const windowMap = store[window];
if (!windowMap) return;
windowMap.delete(namespace);
}
function getAll(namespace, window) {
const map = getNamespaceMap(window, namespace);
if (!map) return new Map();
return new Map(map);
}
// --- Scheduled resets ---
function msUntilNextMidnight() {
const now = new Date();
const next = new Date(now);
next.setHours(24, 0, 0, 0);
return next.getTime() - now.getTime();
}
function msUntilNextMonday() {
const now = new Date();
const day = now.getDay(); // 0=Sun
const daysUntilMonday = day === 0 ? 1 : (8 - day);
const next = new Date(now);
next.setDate(now.getDate() + daysUntilMonday);
next.setHours(0, 0, 0, 0);
return next.getTime() - now.getTime();
}
function msUntilNextMonth() {
const now = new Date();
const next = new Date(now.getFullYear(), now.getMonth() + 1, 1, 0, 0, 0, 0);
return next.getTime() - now.getTime();
}
// Callbacks to run on daily reset (e.g. clear firedToday in patternChecker)
const dailyResetCallbacks = [];
const weeklyResetCallbacks = [];
function onDailyReset(fn) {
dailyResetCallbacks.push(fn);
}
function onWeeklyReset(fn) {
weeklyResetCallbacks.push(fn);
}
// --- Threshold firing state ---
// key -> Set<thresholdMs> that have fired within the key's window.
const firedThresholds = new Map();
// key -> window type used for threshold clearing ("today" | "week" | "month")
const firedThresholdWindows = new Map();
// key -> last-seen timestamp; drives periodic sweep for keys that outlive their window reset.
const firedThresholdLastSeen = new Map();
function clearFiredThresholdsForWindow(windowType) {
for (const [key, mappedWindowType] of firedThresholdWindows.entries()) {
if (mappedWindowType === windowType) {
firedThresholds.delete(key);
firedThresholdWindows.delete(key);
firedThresholdLastSeen.delete(key);
}
}
}
function shouldFireThreshold(key, ageMs, thresholdsMs, windowType) {
if (!Array.isArray(thresholdsMs) || thresholdsMs.length === 0) return null;
if (!['today', 'week', 'month'].includes(windowType)) return null;
firedThresholdWindows.set(key, windowType);
firedThresholdLastSeen.set(key, Date.now());
const firedForKey = firedThresholds.get(key) || new Set();
const sortedThresholds = [...thresholdsMs].sort((a, b) => a - b);
let highestUnfiredCrossed = null;
for (const thresholdMs of sortedThresholds) {
if (ageMs >= thresholdMs && !firedForKey.has(thresholdMs)) {
highestUnfiredCrossed = thresholdMs;
}
}
if (highestUnfiredCrossed === null) return null;
firedForKey.add(highestUnfiredCrossed);
firedThresholds.set(key, firedForKey);
return highestUnfiredCrossed;
}
// --- Escalating cooldown state ---
// key -> { startedAtMs, lastFireAtMs, fireCount }
const escalatingCooldowns = new Map();
function shouldFireCooldownEscalating(key, thresholdsMs) {
if (!Array.isArray(thresholdsMs) || thresholdsMs.length === 0) return null;
const sortedThresholds = [...thresholdsMs].sort((a, b) => a - b);
const now = Date.now();
let state = escalatingCooldowns.get(key);
if (!state) {
state = { startedAtMs: now, lastFireAtMs: null, fireCount: 0, lastUsed: now };
escalatingCooldowns.set(key, state);
}
state.lastUsed = now;
const nextThreshold = sortedThresholds[state.fireCount];
if (typeof nextThreshold !== 'number') return null;
const referenceMs = state.fireCount === 0 ? state.startedAtMs : state.lastFireAtMs;
if ((now - referenceMs) < nextThreshold) return null;
state.fireCount += 1;
state.lastFireAtMs = now;
return nextThreshold;
}
function clearEscalating(key) {
escalatingCooldowns.delete(key);
}
const SWEEP_TTL_MS = 48 * 60 * 60 * 1000;
const SWEEP_INTERVAL_MS = 6 * 60 * 60 * 1000;
function cleanupStaleEscalatingCooldowns(now = Date.now()) {
const cutoff = now - SWEEP_TTL_MS;
for (const [key, state] of escalatingCooldowns.entries()) {
const lastUsed = state.lastUsed || state.lastFireAtMs || state.startedAtMs || 0;
if (lastUsed < cutoff) escalatingCooldowns.delete(key);
}
}
// Sweep every per-Map timestamp-bearing entry older than SWEEP_TTL_MS.
// firedThresholds/firedThresholdWindows are cleared by windowType-resets;
// this sweep covers keys whose window never resets under load.
function sweepPatternStore(now = Date.now()) {
const cutoff = now - SWEEP_TTL_MS;
for (const [key, ts] of cooldowns.entries()) {
if (ts < cutoff) cooldowns.delete(key);
}
for (const [key, ts] of staffLastSeen.entries()) {
if (ts < cutoff) staffLastSeen.delete(key);
}
cleanupStaleEscalatingCooldowns(now);
for (const [key, ts] of firedThresholdLastSeen.entries()) {
if (ts < cutoff) {
firedThresholds.delete(key);
firedThresholdWindows.delete(key);
firedThresholdLastSeen.delete(key);
}
}
}
/**
* Register the module's sweep on the given trackInterval function.
* Called once from the ready handler. Interval is unref'd so it never
* blocks shutdown; trackInterval ensures handleShutdown clears it.
*/
function startSweeps(trackInterval) {
const handle = setInterval(() => sweepPatternStore(), SWEEP_INTERVAL_MS);
if (typeof handle.unref === 'function') handle.unref();
if (typeof trackInterval === 'function') trackInterval(handle);
return handle;
}
function scheduleDailyReset() {
setTimeout(() => {
store.today = new Map();
clearFiredThresholdsForWindow('today');
for (const fn of dailyResetCallbacks) {
try { fn(); } catch (_) {}
}
scheduleDailyReset();
}, msUntilNextMidnight());
}
function scheduleWeeklyReset() {
setTimeout(() => {
store.week = new Map();
clearFiredThresholdsForWindow('week');
for (const fn of weeklyResetCallbacks) {
try { fn(); } catch (_) {}
}
scheduleWeeklyReset();
}, msUntilNextMonday());
}
function scheduleMonthlyReset() {
setTimeout(() => {
store.month = new Map();
clearFiredThresholdsForWindow('month');
scheduleMonthlyReset();
}, msUntilNextMonth());
}
function scheduleResets() {
scheduleDailyReset();
scheduleWeeklyReset();
scheduleMonthlyReset();
}
// --- Cooldown store ---
const cooldowns = new Map();
function setCooldown(key) {
cooldowns.set(key, Date.now());
}
function isOnCooldown(key, cooldownMinutes) {
const last = cooldowns.get(key);
if (!last) return false;
return (Date.now() - last) < cooldownMinutes * 60 * 1000;
}
// --- Staff last-seen tracker (fallback for missing presence intent) ---
const staffLastSeen = new Map();
function updateStaffLastSeen(staffId) {
staffLastSeen.set(staffId, Date.now());
}
function getStaffLastSeen(staffId) {
return staffLastSeen.get(staffId) || null;
}
function isStaffRecentlyActive(staffId, withinMinutes = 60) {
const last = staffLastSeen.get(staffId);
if (!last) return false;
return (Date.now() - last) < withinMinutes * 60 * 1000;
}
module.exports = {
increment,
get,
reset,
getAll,
scheduleResets,
onDailyReset,
onWeeklyReset,
firedThresholds,
shouldFireThreshold,
shouldFireCooldownEscalating,
clearEscalating,
setCooldown,
isOnCooldown,
updateStaffLastSeen,
getStaffLastSeen,
isStaffRecentlyActive,
startSweeps,
sweepPatternStore,
// test-only exports
_internals: { cooldowns, staffLastSeen, escalatingCooldowns, firedThresholds, firedThresholdWindows, firedThresholdLastSeen, SWEEP_TTL_MS }
};