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.
taskbot/src/tasks/complete-reaction.ts

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
}
}