You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
124 lines
4.5 KiB
TypeScript
124 lines
4.5 KiB
TypeScript
import type { Database } from 'bun:sqlite';
|
|
import { isGroupId } from '../utils/whatsapp';
|
|
import { toIsoUTC } from '../utils/datetime';
|
|
import { AllowedGroups } from '../services/allowed-groups';
|
|
import { ResponseQueue } from '../services/response-queue';
|
|
|
|
// ── Env helpers ──────────────────────────────────────────────
|
|
|
|
const DEFAULT_TTL_DAYS = 14;
|
|
|
|
function isEnvFlagEnabled(envKey: string, defaultVal = false): boolean {
|
|
const raw = String(process.env[envKey] || String(defaultVal)).toLowerCase();
|
|
return ['true', '1', 'yes', 'on'].includes(raw);
|
|
}
|
|
|
|
function envNumber(envKey: string, fallback: number): number {
|
|
const n = Number(process.env[envKey]);
|
|
return Number.isFinite(n) && n > 0 ? n : fallback;
|
|
}
|
|
|
|
function envString(envKey: string, fallback: string): string {
|
|
const v = (process.env[envKey] ?? '').trim();
|
|
return v || fallback;
|
|
}
|
|
|
|
// ── DB helpers ───────────────────────────────────────────────
|
|
|
|
interface TaskOrigin {
|
|
chat_id?: string;
|
|
message_id?: string;
|
|
created_at?: string;
|
|
participant?: string | null;
|
|
from_me?: number | boolean | null;
|
|
}
|
|
|
|
/** Query con fallback: si la columna participant/from_me no existe aún (schema antiguo), reintenta sin ellas. */
|
|
function getTaskOrigin(db: Database, taskId: number): TaskOrigin | null {
|
|
try {
|
|
const row = db.prepare(`
|
|
SELECT chat_id, message_id, created_at, participant, from_me
|
|
FROM task_origins WHERE task_id = ?
|
|
`).get(taskId) as TaskOrigin | undefined;
|
|
return row ?? null;
|
|
} catch {
|
|
const row = db.prepare(`
|
|
SELECT chat_id, message_id, created_at
|
|
FROM task_origins WHERE task_id = ?
|
|
`).get(taskId) as TaskOrigin | undefined;
|
|
return row ?? null;
|
|
}
|
|
}
|
|
|
|
// ── Eligibility checks ───────────────────────────────────────
|
|
|
|
function isScopeEligible(chatId: string): boolean {
|
|
const scope = envString('REACTIONS_SCOPE', 'groups').toLowerCase();
|
|
return scope === 'all' || isGroupId(chatId);
|
|
}
|
|
|
|
function isWithinTtl(origin: TaskOrigin): boolean {
|
|
const ttlDays = envNumber('REACTIONS_TTL_DAYS', DEFAULT_TTL_DAYS);
|
|
const maxAgeMs = ttlDays * 24 * 60 * 60 * 1000;
|
|
|
|
const createdMs = Date.parse(toIsoUTC(String(origin.created_at || '')));
|
|
return Number.isFinite(createdMs) && (Date.now() - createdMs <= maxAgeMs);
|
|
}
|
|
|
|
function isGatingAllowed(chatId: string): boolean {
|
|
if (!isGroupId(chatId)) return true;
|
|
const mode = envString('GROUP_GATING_MODE', 'off').toLowerCase();
|
|
if (mode !== 'enforce') return true;
|
|
try {
|
|
return AllowedGroups.isAllowed(chatId);
|
|
} catch {
|
|
return true; // fail open
|
|
}
|
|
}
|
|
|
|
// ── Reaction options builder ─────────────────────────────────
|
|
|
|
function buildReactionOpts(origin: TaskOrigin): { participant?: string; fromMe?: boolean } {
|
|
const participant = origin.participant ? String(origin.participant) : undefined;
|
|
const fromMe = (origin.from_me === 1 || origin.from_me === true) ? true : undefined;
|
|
|
|
const opts: { participant?: string; fromMe?: boolean } = {};
|
|
if (participant !== undefined) opts.participant = participant;
|
|
if (typeof fromMe === 'boolean') opts.fromMe = fromMe;
|
|
return Object.keys(opts).length > 0 ? opts : {};
|
|
}
|
|
|
|
// ── Public API ───────────────────────────────────────────────
|
|
|
|
/**
|
|
* Publica una reacción ✅ al mensaje origen de la tarea si:
|
|
* - REACTIONS_ENABLED está activado,
|
|
* - scope permite (all o solo grupos),
|
|
* - está dentro del TTL configurado (por defecto 14 días),
|
|
* - y pasa el gating de grupos en modo 'enforce'.
|
|
*
|
|
* No lanza errores (no debe bloquear el flujo de completado).
|
|
*/
|
|
export function enqueueCompletionReactionIfEligible(db: Database, taskId: number): void {
|
|
try {
|
|
if (!isEnvFlagEnabled('REACTIONS_ENABLED')) return;
|
|
|
|
const origin = getTaskOrigin(db, taskId);
|
|
if (!origin?.chat_id || !origin.message_id) return;
|
|
|
|
const chatId = String(origin.chat_id);
|
|
if (!isScopeEligible(chatId)) return;
|
|
if (!isWithinTtl(origin)) return;
|
|
if (!isGatingAllowed(chatId)) return;
|
|
|
|
ResponseQueue.enqueueReaction(
|
|
chatId,
|
|
String(origin.message_id),
|
|
'✅',
|
|
buildReactionOpts(origin),
|
|
).catch(() => {});
|
|
} catch {
|
|
// no-op: nunca bloquear el flujo de completado
|
|
}
|
|
}
|