import { createHash } from 'node:crypto'; import type { ConversationEventRepository, AttributionConfidence, ConversationEventKind, } from '../database/conversation-event-repository.js'; import type { ConversationAutomationClaimRecord, SentMessageRecord } from '../database/repositories.js'; import type { MediaLibrarySend } from './media-library-types.js'; import type { ChatMessage } from './chat/types.js'; import { conversationMessageFingerprint } from './conversation-state.js'; import type { AttributedConversationMessage } from './conversation-learning-types.js'; export interface ConversationAttributionInput { conversationId: string; messages: readonly ChatMessage[]; opener: SentMessageRecord | undefined; claims: readonly ConversationAutomationClaimRecord[]; mediaSends: readonly MediaLibrarySend[]; } /** * Deterministically attributes a Matrix timeline. Event IDs win over all * fingerprint fallbacks; remaining self-authored events are human-owned with * inferred confidence, matching the automation safety boundary. */ export function attributeConversationMessages( input: ConversationAttributionInput, repository?: ConversationEventRepository, observedAt = Date.now(), ): AttributedConversationMessage[] { const claimsByEventId = new Map(input.claims.flatMap((claim) => claim.matrixEventId ? [[claim.matrixEventId, claim] as const] : [])); const claimsByFingerprint = new Map(input.claims.flatMap((claim) => claim.replyText ? [[conversationMessageFingerprint(claim.replyText), claim] as const] : [])); const mediaByEventId = new Map(); for (const send of input.mediaSends) { if (send.matrixMediaEventId) { mediaByEventId.set(send.matrixMediaEventId, { send, kind: send.initiatedBy === 'human' ? 'manual-library-media' : 'automated-library-media', }); } if (send.matrixTextEventId && !send.matrixTextEventId.startsWith('manual-') && send.matrixTextEventId !== 'unconfirmed') { mediaByEventId.set(send.matrixTextEventId, { send, kind: 'library-reciprocal' }); } } return input.messages.map((message, index) => { const eventId = message.eventId ?? syntheticEventId(message, index); const existing = repository?.get(input.conversationId, eventId); let actor: AttributedConversationMessage['actor']; let kind: ConversationEventKind; let confidence: AttributionConfidence; let sourceReference: string | null = null; if (!message.isSelf) { actor = 'recipient'; kind = (message.media?.length ?? 0) > 0 ? 'inbound-media' : 'inbound-text'; confidence = message.eventId ? 'exact' : 'inferred'; } else if (existing?.actor === 'human' && existing.attributionConfidence === 'exact') { actor = 'human'; kind = existing.eventKind; confidence = 'exact'; sourceReference = existing.sourceReference; } else if (message.eventId && input.opener?.matrixEventId === message.eventId) { actor = 'ai'; kind = 'opener'; confidence = 'exact'; sourceReference = input.opener ? `sent-message:${input.opener.id}` : null; } else if (message.eventId && claimsByEventId.has(message.eventId)) { const claim = claimsByEventId.get(message.eventId)!; actor = 'ai'; kind = 'automation-reply'; confidence = 'exact'; sourceReference = `claim:${claim.incomingKey}`; } else if (message.eventId && mediaByEventId.has(message.eventId)) { const matched = mediaByEventId.get(message.eventId)!; actor = matched.send.initiatedBy === 'human' ? 'human' : 'ai'; kind = matched.kind; confidence = 'exact'; sourceReference = `media-library-send:${matched.send.id}`; } else if (isOpenerFingerprint(message, input.opener)) { actor = 'ai'; kind = 'opener'; confidence = 'fingerprint'; sourceReference = input.opener ? `sent-message:${input.opener.id}` : null; } else { const claim = claimsByFingerprint.get(conversationMessageFingerprint(message.text)); if (claim) { actor = 'ai'; kind = 'automation-reply'; confidence = 'fingerprint'; sourceReference = `claim:${claim.incomingKey}`; } else { actor = 'human'; kind = 'manual-text'; confidence = message.eventId ? 'inferred' : 'inferred'; } } repository?.upsert({ conversationId: input.conversationId, eventId, eventTimestamp: message.timestamp, direction: message.isSelf ? 'outbound' : 'inbound', actor, eventKind: kind, text: message.text, sourceReference, attributionConfidence: confidence, observedAt, }); return { eventId, timestamp: message.timestamp, isSelf: message.isSelf, text: message.text, mediaCount: message.media?.length ?? 0, actor, kind, confidence, }; }); } function isOpenerFingerprint(message: ChatMessage, opener: SentMessageRecord | undefined): boolean { if (!opener || !message.isSelf) return false; if (Math.abs(message.timestamp - opener.sentAt) > 60_000) return false; return conversationMessageFingerprint(message.text) === conversationMessageFingerprint(opener.message); } function syntheticEventId(message: ChatMessage, index: number): string { const digest = createHash('sha256') .update(`${message.timestamp}\u0000${message.isSelf ? 'self' : 'recipient'}\u0000${message.text}\u0000${index}`) .digest('hex') .slice(0, 20); return `legacy:${digest}`; }