feat: onboarding en /t nueva filtrando IDs <14 y migrar last_command_at

Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
main
brobert 4 days ago
parent d4a998cdf1
commit 394f28e5cc

@ -10,6 +10,7 @@ import { getQuickHelp, getFullHelp } from './messages/help';
import { IdentityService } from './identity';
import { AllowedGroups } from './allowed-groups';
import { Metrics } from './metrics';
import { ResponseQueue } from './response-queue';
import { randomTokenBase64Url, sha256Hex } from '../utils/crypto';
type CommandContext = {
@ -1131,6 +1132,15 @@ export class CommandService {
);
// 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 {
if (groupIdToUse && isGroupId(groupIdToUse) && context.messageId) {
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); // 510s
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 | lv | 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;
}

@ -1279,4 +1279,29 @@ export class GroupSyncService {
return { added: 0, updated: 0, deactivated: 0 };
}
}
/**
* Devuelve los IDs de usuario activos del grupo, filtrados a dígitos puros con longitud < 14.
* No devuelve duplicados.
*/
public static listActiveMemberIds(groupId: string): string[] {
if (!groupId) return [];
try {
const rows = this.dbInstance.prepare(`
SELECT user_id
FROM group_members
WHERE group_id = ? AND is_active = 1
`).all(groupId) as Array<{ user_id: string }>;
const out = new Set<string>();
for (const r of rows) {
const uid = String(r.user_id || '').trim();
if (/^\d+$/.test(uid) && uid.length < 14) {
out.add(uid);
}
}
return Array.from(out);
} catch {
return [];
}
}
}

@ -107,6 +107,97 @@ export const ResponseQueue = {
}
},
// Encolar un DM de onboarding (part=1 inmediato, part=2 con retraso)
enqueueOnboarding(
recipient: string,
message: string,
metadata: {
variant: 'initial' | 'reminder';
part: 1 | 2;
bundle_id: string;
group_id?: string | null;
task_id?: number | null;
display_code?: number | null;
},
delayMs?: number
): void {
if (!recipient || !message) return;
const botNumber = (process.env.CHATBOT_PHONE_NUMBER || '').trim();
if (botNumber && recipient === botNumber) {
try { Metrics.inc('onboarding_dm_skipped_total', 1, { reason: 'bot_number', group_id: String(metadata.group_id || '') }); } catch {}
return;
}
const metaObj: any = {
kind: 'onboarding',
variant: metadata.variant,
part: metadata.part,
bundle_id: metadata.bundle_id,
group_id: metadata.group_id ?? null,
task_id: metadata.task_id ?? null,
display_code: metadata.display_code ?? null
};
const nextAt = delayMs && delayMs > 0 ? this.futureIso(delayMs) : this.nowIso();
this.dbInstance.prepare(`
INSERT INTO response_queue (recipient, message, metadata, next_attempt_at)
VALUES (?, ?, ?, ?)
`).run(recipient, message, JSON.stringify(metaObj), nextAt);
try { Metrics.inc('onboarding_dm_sent_total', 1, { variant: metadata.variant, part: String(metadata.part), group_id: String(metadata.group_id || '') }); } catch {}
},
// Estadísticas de onboarding por destinatario (consulta simple sobre response_queue)
getOnboardingStats(recipient: string): { total: number; lastSentAt: string | null; firstInitialAt?: string | null; lastVariant?: 'initial' | 'reminder' | null } {
if (!recipient) return { total: 0, lastSentAt: null, firstInitialAt: undefined, lastVariant: null };
const rows = this.dbInstance.prepare(`
SELECT status, created_at, updated_at, metadata
FROM response_queue
WHERE recipient = ? AND metadata IS NOT NULL
`).all(recipient) as Array<{ status: string; created_at: string; updated_at: string; metadata: string | null }>;
let total = 0;
let lastSentAt: string | null = null;
let firstInitialAt: string | null | undefined = undefined;
let lastVariant: 'initial' | 'reminder' | null = null;
let lastTsMs = -1;
for (const r of rows) {
let meta: any = null;
try { meta = r.metadata ? JSON.parse(r.metadata) : null; } catch { meta = null; }
if (!meta || meta.kind !== 'onboarding') continue;
total++;
// Elegir timestamp de referencia
const tRaw = (r.updated_at || r.created_at || '').toString();
const iso = tRaw.includes('T') ? tRaw : (tRaw.replace(' ', 'T') + 'Z');
const ts = Date.parse(iso);
if (Number.isFinite(ts) && ts > lastTsMs) {
lastTsMs = ts;
lastSentAt = tRaw || null;
lastVariant = (meta.variant === 'reminder' ? 'reminder' : 'initial');
}
// Primer initial (preferimos part=1)
if (meta.variant === 'initial') {
const created = (r.created_at || '').toString();
if (!firstInitialAt) {
firstInitialAt = created || null;
} else {
// mantener el más antiguo
try {
const curIso = (firstInitialAt as string).includes('T') ? firstInitialAt as string : ((firstInitialAt as string).replace(' ', 'T') + 'Z');
const curMs = Date.parse(curIso);
const newIso = created.includes('T') ? created : (created.replace(' ', 'T') + 'Z');
const newMs = Date.parse(newIso);
if (Number.isFinite(newMs) && (!Number.isFinite(curMs) || newMs < curMs)) {
firstInitialAt = created || null;
}
} catch {}
}
}
}
return { total, lastSentAt, firstInitialAt, lastVariant };
},
// Encolar una reacción con idempotencia (24h) usando metadata canónica
async enqueueReaction(chatId: string, messageId: string, emoji: string, opts?: { participant?: string; fromMe?: boolean }): Promise<void> {
try {

Loading…
Cancel
Save