|
|
|
@ -10,6 +10,7 @@ import { getQuickHelp, getFullHelp } from './messages/help';
|
|
|
|
import { IdentityService } from './identity';
|
|
|
|
import { IdentityService } from './identity';
|
|
|
|
import { AllowedGroups } from './allowed-groups';
|
|
|
|
import { AllowedGroups } from './allowed-groups';
|
|
|
|
import { Metrics } from './metrics';
|
|
|
|
import { Metrics } from './metrics';
|
|
|
|
|
|
|
|
import { ResponseQueue } from './response-queue';
|
|
|
|
import { randomTokenBase64Url, sha256Hex } from '../utils/crypto';
|
|
|
|
import { randomTokenBase64Url, sha256Hex } from '../utils/crypto';
|
|
|
|
|
|
|
|
|
|
|
|
type CommandContext = {
|
|
|
|
type CommandContext = {
|
|
|
|
@ -1131,6 +1132,15 @@ export class CommandService {
|
|
|
|
);
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
// Registrar origen del comando para esta tarea (Fase 1)
|
|
|
|
// Registrar origen del comando para esta tarea (Fase 1)
|
|
|
|
|
|
|
|
// Registrar interacción del usuario (last_command_at)
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
|
|
const ensured = ensureUserExists(context.sender, this.dbInstance);
|
|
|
|
|
|
|
|
if (ensured) {
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
|
|
this.dbInstance.prepare(`UPDATE users SET last_command_at = strftime('%Y-%m-%d %H:%M:%f','now') WHERE id = ?`).run(ensured);
|
|
|
|
|
|
|
|
} catch {}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
} catch {}
|
|
|
|
try {
|
|
|
|
try {
|
|
|
|
if (groupIdToUse && isGroupId(groupIdToUse) && context.messageId) {
|
|
|
|
if (groupIdToUse && isGroupId(groupIdToUse) && context.messageId) {
|
|
|
|
const participant = typeof context.participant === 'string' ? context.participant : null;
|
|
|
|
const participant = typeof context.participant === 'string' ? context.participant : null;
|
|
|
|
@ -1234,6 +1244,121 @@ export class CommandService {
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Fase 2: disparar paquete de onboarding (2 DMs) tras crear tarea en grupo
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
|
|
const isTest = String(process.env.NODE_ENV || '').toLowerCase() === 'test';
|
|
|
|
|
|
|
|
const enabledBase = ['true','1','yes','on'].includes(String(process.env.ONBOARDING_DM_ENABLED || '').toLowerCase());
|
|
|
|
|
|
|
|
const enabled = enabledBase && (!isTest || String(process.env.ONBOARDING_ENABLE_IN_TEST || '').toLowerCase() === 'true');
|
|
|
|
|
|
|
|
const gid = groupIdToUse || (isGroupId(context.groupId) ? context.groupId : null);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if (!enabled) {
|
|
|
|
|
|
|
|
try { Metrics.inc('onboarding_dm_skipped_total', 1, { reason: 'disabled', group_id: String(gid || '') }); } catch {}
|
|
|
|
|
|
|
|
} else if (!gid) {
|
|
|
|
|
|
|
|
try { Metrics.inc('onboarding_dm_skipped_total', 1, { reason: 'no_group', group_id: String(context.groupId || '') }); } catch {}
|
|
|
|
|
|
|
|
} else {
|
|
|
|
|
|
|
|
// Gating enforce
|
|
|
|
|
|
|
|
let allowed = true;
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
|
|
const mode = String(process.env.GROUP_GATING_MODE || 'off').toLowerCase();
|
|
|
|
|
|
|
|
if (mode === 'enforce') {
|
|
|
|
|
|
|
|
try { (AllowedGroups as any).dbInstance = this.dbInstance; } catch {}
|
|
|
|
|
|
|
|
allowed = AllowedGroups.isAllowed(gid);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
} catch {}
|
|
|
|
|
|
|
|
if (!allowed) {
|
|
|
|
|
|
|
|
try { Metrics.inc('onboarding_dm_skipped_total', 1, { reason: 'not_allowed', group_id: String(gid) }); } catch {}
|
|
|
|
|
|
|
|
} else {
|
|
|
|
|
|
|
|
const displayCode = createdTask?.display_code;
|
|
|
|
|
|
|
|
if (!(typeof displayCode === 'number' && Number.isFinite(displayCode))) {
|
|
|
|
|
|
|
|
try { Metrics.inc('onboarding_dm_skipped_total', 1, { reason: 'no_display_code', group_id: String(gid) }); } catch {}
|
|
|
|
|
|
|
|
} else {
|
|
|
|
|
|
|
|
// Candidatos
|
|
|
|
|
|
|
|
let members = GroupSyncService.listActiveMemberIds(gid);
|
|
|
|
|
|
|
|
const bot = String(process.env.CHATBOT_PHONE_NUMBER || '').trim();
|
|
|
|
|
|
|
|
const exclude = new Set<string>([createdBy, ...assignmentUserIds]);
|
|
|
|
|
|
|
|
members = members
|
|
|
|
|
|
|
|
.filter(id => /^\d+$/.test(id) && id.length < 14)
|
|
|
|
|
|
|
|
.filter(id => !exclude.has(id))
|
|
|
|
|
|
|
|
.filter(id => !bot || id !== bot);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if (members.length === 0) {
|
|
|
|
|
|
|
|
try { Metrics.inc('onboarding_dm_skipped_total', 1, { reason: 'no_members', group_id: String(gid) }); } catch {}
|
|
|
|
|
|
|
|
} else {
|
|
|
|
|
|
|
|
const capRaw = Number(process.env.ONBOARDING_EVENT_CAP);
|
|
|
|
|
|
|
|
const cap = Number.isFinite(capRaw) && capRaw > 0 ? Math.floor(capRaw) : 30;
|
|
|
|
|
|
|
|
let recipients = members;
|
|
|
|
|
|
|
|
if (recipients.length > cap) {
|
|
|
|
|
|
|
|
try { Metrics.inc('onboarding_recipients_capped_total', recipients.length - cap, { group_id: String(gid) }); } catch {}
|
|
|
|
|
|
|
|
recipients = recipients.slice(0, cap);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const cooldownRaw = Number(process.env.ONBOARDING_DM_COOLDOWN_DAYS);
|
|
|
|
|
|
|
|
const cooldownDays = Number.isFinite(cooldownRaw) && cooldownRaw >= 0 ? Math.floor(cooldownRaw) : 14;
|
|
|
|
|
|
|
|
const delay2 = 5000 + Math.floor(Math.random() * 5001); // 5–10s
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const groupLabel = GroupSyncService.activeGroupsCache.get(gid) || gid;
|
|
|
|
|
|
|
|
const codeStr = String(displayCode);
|
|
|
|
|
|
|
|
const desc = (description || '(sin descripción)').trim();
|
|
|
|
|
|
|
|
const shortDesc = desc.length > 100 ? (desc.slice(0, 100) + '…') : desc;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const msg1 = `Hola, soy el bot de tareas. En ‘${groupLabel}’ acaban de crear una tarea: #${codeStr} ${shortDesc}
|
|
|
|
|
|
|
|
Encárgate: /t tomar ${codeStr} · Más info: /t info
|
|
|
|
|
|
|
|
Nota: nunca respondo en grupos; solo por privado.`;
|
|
|
|
|
|
|
|
const msg2 = `Guía rápida (este es un mensaje único):
|
|
|
|
|
|
|
|
· Tus tareas: /t mias · Todas: /t todas
|
|
|
|
|
|
|
|
· Recordatorios: /t configurar diario | l‑v | semanal | off
|
|
|
|
|
|
|
|
· Web: /t web`;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
for (const rcpt of recipients) {
|
|
|
|
|
|
|
|
const stats = ResponseQueue.getOnboardingStats(rcpt);
|
|
|
|
|
|
|
|
let variant: 'initial' | 'reminder' | null = null;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if (!stats || (stats.total || 0) === 0) {
|
|
|
|
|
|
|
|
variant = 'initial';
|
|
|
|
|
|
|
|
} else if (stats.firstInitialAt) {
|
|
|
|
|
|
|
|
let firstMs = NaN;
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
|
|
const s = String(stats.firstInitialAt);
|
|
|
|
|
|
|
|
const iso = s.includes('T') ? s : (s.replace(' ', 'T') + 'Z');
|
|
|
|
|
|
|
|
firstMs = Date.parse(iso);
|
|
|
|
|
|
|
|
} catch {}
|
|
|
|
|
|
|
|
const nowMs = Date.now();
|
|
|
|
|
|
|
|
const okCooldown = Number.isFinite(firstMs) ? (nowMs - firstMs) >= cooldownDays * 24 * 60 * 60 * 1000 : false;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Interacción del usuario desde el primer paquete
|
|
|
|
|
|
|
|
let hadInteraction = false;
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
|
|
const row = this.dbInstance.prepare(`SELECT last_command_at FROM users WHERE id = ?`).get(rcpt) as any;
|
|
|
|
|
|
|
|
const lcRaw = row?.last_command_at ? String(row.last_command_at) : null;
|
|
|
|
|
|
|
|
if (lcRaw) {
|
|
|
|
|
|
|
|
const lcIso = lcRaw.includes('T') ? lcRaw : (lcRaw.replace(' ', 'T') + 'Z');
|
|
|
|
|
|
|
|
const lcMs = Date.parse(lcIso);
|
|
|
|
|
|
|
|
hadInteraction = Number.isFinite(lcMs) && Number.isFinite(firstMs) && lcMs > firstMs;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
} catch {}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if (okCooldown && !hadInteraction) {
|
|
|
|
|
|
|
|
variant = 'reminder';
|
|
|
|
|
|
|
|
} else {
|
|
|
|
|
|
|
|
try { Metrics.inc('onboarding_dm_skipped_total', 1, { reason: hadInteraction ? 'had_interaction' : 'cooldown_active', group_id: String(gid) }); } catch {}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if (!variant) continue;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const bundleId = randomTokenBase64Url(12);
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
|
|
ResponseQueue.enqueueOnboarding(rcpt, msg1, { variant, part: 1, bundle_id: bundleId, group_id: gid, task_id: taskId, display_code: displayCode }, 0);
|
|
|
|
|
|
|
|
ResponseQueue.enqueueOnboarding(rcpt, msg2, { variant, part: 2, bundle_id: bundleId, group_id: gid, task_id: taskId, display_code: displayCode }, delay2);
|
|
|
|
|
|
|
|
try { Metrics.inc('onboarding_bundle_sent_total', 1, { variant, group_id: String(gid) }); } catch {}
|
|
|
|
|
|
|
|
} catch {}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
} catch {}
|
|
|
|
|
|
|
|
|
|
|
|
return responses;
|
|
|
|
return responses;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|