Ctrl K
ComunidadGuías…

src/server/contacts.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { dataPath } from './paths.ts';
import { dataSync } from './data-sync.ts';

export interface Contact {
  id: string;
  name: string;
  emails: string[];
  slackIds: string[];
  role?: string;
  isOperator: boolean;
  isOwner?: boolean;
  firstSeen: number;
  lastSeen: number;
}

const CONTACTS_PATH = dataPath('contacts.json');
let contacts: Contact[] = [];
let byEmail = new Map<string, Contact>();
let bySlackId = new Map<string, Contact>();
let dirty = false;

function rebuildIndexes() {
  byEmail = new Map();
  bySlackId = new Map();
  for (const c of contacts) {
    for (const e of c.emails) byEmail.set(e.toLowerCase(), c);
    for (const s of c.slackIds) bySlackId.set(s, c);
  }
}

function load() {
  if (!existsSync(CONTACTS_PATH)) { contacts = []; rebuildIndexes(); return; }
  try {
    contacts = JSON.parse(readFileSync(CONTACTS_PATH, 'utf-8'));
  } catch (err) { console.error('[contacts] Failed to load contacts.json:', (err as Error).message); contacts = []; }
  rebuildIndexes();
}

function save() {
  if (!dirty) return;
  writeFileSync(CONTACTS_PATH, JSON.stringify(contacts, null, 2));
  dirty = false;
  dataSync.trackWrite('contacts.json');
}

load();

export function find(opts: { email?: string; slackId?: string }): Contact | null {
  if (opts.email) { const c = byEmail.get(opts.email.toLowerCase()); if (c) return c; }
  if (opts.slackId) { const c = bySlackId.get(opts.slackId); if (c) return c; }
  return null;
}

function mergeContacts(a: Contact, b: Contact): Contact {
  const merged: Contact = {
    id: a.firstSeen <= b.firstSeen ? a.id : b.id,
    name: b.lastSeen >= a.lastSeen ? (b.name || a.name) : (a.name || b.name),
    emails: [...new Set([...a.emails, ...b.emails])],
    slackIds: [...new Set([...a.slackIds, ...b.slackIds])],
    role: (b.lastSeen >= a.lastSeen ? b.role : a.role) || a.role || b.role,
    isOperator: a.isOperator || b.isOperator,
    isOwner: a.isOwner || b.isOwner,
    firstSeen: Math.min(a.firstSeen, b.firstSeen),
    lastSeen: Math.max(a.lastSeen, b.lastSeen),
  };
  contacts = contacts.filter(c => c !== a && c !== b);
  contacts.push(merged);
  dirty = true;
  rebuildIndexes();
  return merged;
}

export function upsert(opts: { email?: string; slackId?: string; name?: string; role?: string }): Contact {
  const byE = opts.email ? byEmail.get(opts.email.toLowerCase()) : null;
  const byS = opts.slackId ? bySlackId.get(opts.slackId) : null;

  if (byE && byS && byE !== byS) {
    const merged = mergeContacts(byE, byS);
    if (opts.name) merged.name = opts.name;
    if (opts.role) merged.role = opts.role;
    merged.lastSeen = Date.now();
    dirty = true;
    save();
    console.log(`[contacts] Merged: ${merged.emails.join(',')} + ${merged.slackIds.join(',')}`);
    return merged;
  }

  const existing = byE || byS;
  if (existing) {
    let changed = false;
    if (opts.email && !existing.emails.includes(opts.email.toLowerCase())) {
      existing.emails.push(opts.email.toLowerCase());
      changed = true;
    }
    if (opts.slackId && !existing.slackIds.includes(opts.slackId)) {
      existing.slackIds.push(opts.slackId);
      changed = true;
    }
    if (opts.name && opts.name !== existing.name) {
      existing.name = opts.name;
      changed = true;
    }
    if (opts.role && opts.role !== existing.role) {
      existing.role = opts.role;
      changed = true;
    }
    existing.lastSeen = Date.now();
    if (changed) {
      dirty = true;
      rebuildIndexes();
      save();
    }
    return existing;
  }

  const contact: Contact = {
    id: crypto.randomUUID(),
    name: opts.name || opts.email?.split('@')[0] || 'Unknown',
    emails: opts.email ? [opts.email.toLowerCase()] : [],
    slackIds: opts.slackId ? [opts.slackId] : [],
    role: opts.role,
    isOperator: false,
    firstSeen: Date.now(),
    lastSeen: Date.now(),
  };
  contacts.push(contact);
  dirty = true;
  rebuildIndexes();
  save();
  console.log(`[contacts] New: ${contact.name} (${[...contact.emails, ...contact.slackIds].join(', ')})`);
  return contact;
}

export function seedOperators(whitelist: string[]) {
  for (const email of whitelist) {
    const existing = find({ email });
    const c = upsert({ email, ...(existing ? {} : { name: email.split('@')[0] }) });
    if (!c.isOperator) { c.isOperator = true; dirty = true; }
  }
  save();
}

export function formatUserBlock(contact: Contact | null): string {
  if (!contact) return '<current_user>unknown</current_user>';
  const lines = [`name: ${contact.name}`];
  if (contact.emails.length) lines.push(`email: ${contact.emails[0]}`);
  if (contact.role) lines.push(`title: ${contact.role}`);
  if (contact.isOwner) lines.push('role: owner');
  else if (contact.isOperator) lines.push('role: operator');
  if (contact.slackIds.length) lines.push(`slack: ${contact.slackIds[0]}`);
  // Delivery routing is stated EXPLICITLY, not left to inference. A long web session (whose
  // opening brief is many compactions gone) otherwise resolves "who gets this deliverable" from
  // the ambient workspace — where the owner is named on every other line — and DMs the wrong
  // person. Real incident 2026-09-09: a video finished for an operator was delivered to the
  // owner's Slack DM. The slack id is included for the same reason: without it, "DM the person
  // who asked" needs a lookup the model may resolve against the wrong name.
  lines.push(
    'ROUTING: this is who you are talking to. Every result, deliverable, report or file you send',
    'goes to THIS person — their Slack DM (slack id above) or their email — unless the request',
    'explicitly names another recipient. Do NOT infer a recipient from who owns the deployment,',
    'who is named most often in a skill or the workspace, or who usually receives reports.',
  );
  return `<current_user>\n${lines.join('\n')}\n</current_user>`;
}

export function formatRoster(): string {
  if (!contacts.length) return '';
  const operators = contacts.filter(c => c.isOperator);
  const others = contacts.filter(c => !c.isOperator);
  const fmt = (c: Contact, tag: string) => {
    const parts = [c.emails[0] || 'no email', tag];
    if (c.role) parts.push(c.role);
    if (c.slackIds.length) parts.push(`slack:${c.slackIds.join(',')}`);
    return `- ${c.name} (${parts.join(', ')})`;
  };
  const lines = [
    ...operators.map(c => fmt(c, c.isOwner ? 'owner' : 'operator')),
    ...others.map(c => fmt(c, 'contact')),
  ];
  return `<known_contacts>\n${lines.join('\n')}\n</known_contacts>`;
}

export function getByRole(role: string): Contact[] {
  if (role === 'owner') return contacts.filter(c => c.isOwner);
  if (role === 'operator') return contacts.filter(c => c.isOperator);
  return contacts.filter(c => c.role === role);
}

export function getAll(): Contact[] { return contacts; }