diff --git a/apps/web/src/lib/server/env.ts b/apps/web/src/lib/server/env.ts index dd99706..188684c 100644 --- a/apps/web/src/lib/server/env.ts +++ b/apps/web/src/lib/server/env.ts @@ -44,3 +44,10 @@ export const icsRateLimitPerMin = Math.max(0, Math.floor(ICS_RATE_LIMIT_PER_MIN) const UNCOMPLETE_WINDOW_MIN_RAW = Number(env.UNCOMPLETE_WINDOW_MIN || 1440); export const UNCOMPLETE_WINDOW_MIN = Math.max(1, Math.floor(UNCOMPLETE_WINDOW_MIN_RAW)); export const uncompleteWindowMs = UNCOMPLETE_WINDOW_MIN * 60 * 1000; + +// Reacciones (flags de característica para la web) +const REACTIONS_TTL_DAYS_RAW = Number(env.REACTIONS_TTL_DAYS || 14); +export const REACTIONS_TTL_DAYS = Math.max(1, Math.floor(REACTIONS_TTL_DAYS_RAW)); +export const REACTIONS_ENABLED = toBool(env.REACTIONS_ENABLED || ''); +export const REACTIONS_SCOPE = ((env.REACTIONS_SCOPE || 'groups').trim().toLowerCase() === 'all' ? 'all' : 'groups'); +export const GROUP_GATING_MODE = (env.GROUP_GATING_MODE || 'off').trim().toLowerCase(); diff --git a/apps/web/src/routes/api/tasks/[id]/complete/+server.ts b/apps/web/src/routes/api/tasks/[id]/complete/+server.ts index ead73aa..6ab6da3 100644 --- a/apps/web/src/routes/api/tasks/[id]/complete/+server.ts +++ b/apps/web/src/routes/api/tasks/[id]/complete/+server.ts @@ -1,5 +1,6 @@ import type { RequestHandler } from './$types'; import { getDb } from '$lib/server/db'; +import { REACTIONS_ENABLED, REACTIONS_TTL_DAYS, REACTIONS_SCOPE, GROUP_GATING_MODE } from '$lib/server/env'; export const POST: RequestHandler = async (event) => { const userId = event.locals.userId ?? null; @@ -101,6 +102,75 @@ export const POST: RequestHandler = async (event) => { const statusStr = Number(updated.completed || 0) === 1 ? 'updated' : 'already'; + // Encolar reacción ✅ desde la web si procede (idéntico formato al bot) + try { + if (statusStr === 'updated' && REACTIONS_ENABLED) { + // Buscar origen con columnas opcionales (participant/from_me) si existen + let origin: any = null; + try { + origin = db.prepare(` + SELECT chat_id, message_id, created_at, participant, from_me + FROM task_origins + WHERE task_id = ? + `).get(taskId) as any; + } catch { + origin = db.prepare(` + SELECT chat_id, message_id, created_at + FROM task_origins + WHERE task_id = ? + `).get(taskId) as any; + } + + if (origin && origin.chat_id && origin.message_id) { + const chatId = String(origin.chat_id); + + // Scope: por defecto solo reaccionar en grupos + if (REACTIONS_SCOPE === 'all' || chatId.endsWith('@g.us')) { + // TTL (por defecto 14 días) + const ttlMs = REACTIONS_TTL_DAYS * 24 * 60 * 60 * 1000; + const createdRaw = String(origin.created_at || ''); + const createdIso = createdRaw.includes('T') ? createdRaw : (createdRaw.replace(' ', 'T') + 'Z'); + const createdMs = Date.parse(createdIso); + const withinTtl = Number.isFinite(createdMs) ? (Date.now() - createdMs <= ttlMs) : false; + + // Gating 'enforce' (solo aplica a grupos) + let allowed = true; + if (GROUP_GATING_MODE === 'enforce' && chatId.endsWith('@g.us')) { + const row = db.prepare(`SELECT 1 FROM allowed_groups WHERE group_id = ? AND status = 'allowed' LIMIT 1`).get(chatId) as any; + allowed = !!row; + } + + if (withinTtl && allowed) { + // Idempotencia 24h por metadata canónica exacta + const nowIso = new Date().toISOString().replace('T', ' ').replace('Z', ''); + const cutoff = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString().replace('T', ' ').replace('Z', ''); + + const meta: any = { kind: 'reaction', emoji: '✅', chatId, messageId: String(origin.message_id) }; + if (origin && (origin.from_me === 1 || origin.from_me === true)) meta.fromMe = true; + if (origin && origin.participant) meta.participant = String(origin.participant); + const metadata = JSON.stringify(meta); + + const exists = db.prepare(` + SELECT 1 + FROM response_queue + WHERE metadata = ? + AND status IN ('queued','processing','sent') + AND (updated_at > ? OR created_at > ?) + LIMIT 1 + `).get(metadata, cutoff, cutoff) as any; + + if (!exists) { + db.prepare(` + INSERT INTO response_queue (recipient, message, metadata, next_attempt_at) + VALUES (?, ?, ?, ?) + `).run(chatId, '', metadata, nowIso); + } + } + } + } + } + } catch {} + const body = { status: statusStr, task: {