Files
RedditBot/src/database/conversation-event-repository.ts
T

163 lines
6.0 KiB
TypeScript

import { createHash } from 'node:crypto';
import type Database from 'better-sqlite3';
export const CONVERSATION_EVENT_ACTORS = ['recipient', 'ai', 'human', 'system', 'unknown'] as const;
export type ConversationEventActor = (typeof CONVERSATION_EVENT_ACTORS)[number];
export const CONVERSATION_EVENT_KINDS = [
'inbound-text',
'inbound-media',
'opener',
'automation-reply',
'manual-text',
'automated-library-media',
'manual-library-media',
'library-reciprocal',
'unknown-outbound',
] as const;
export type ConversationEventKind = (typeof CONVERSATION_EVENT_KINDS)[number];
export type AttributionConfidence = 'exact' | 'fingerprint' | 'inferred';
export interface ConversationEventRecord {
accountId: string;
conversationId: string;
eventId: string;
eventTimestamp: number;
direction: 'inbound' | 'outbound';
actor: ConversationEventActor;
eventKind: ConversationEventKind;
textSha256: string | null;
sourceReference: string | null;
attributionConfidence: AttributionConfidence;
observedAt: number;
}
export interface UpsertConversationEvent {
conversationId: string;
eventId: string;
eventTimestamp: number;
direction: 'inbound' | 'outbound';
actor: ConversationEventActor;
eventKind: ConversationEventKind;
text?: string | null;
sourceReference?: string | null;
attributionConfidence: AttributionConfidence;
observedAt?: number;
}
/** Account-scoped, text-minimising provenance for Matrix conversation events. */
export class ConversationEventRepository {
constructor(
private readonly db: Database.Database,
private readonly accountId: string,
) {}
upsert(input: UpsertConversationEvent): ConversationEventRecord {
const observedAt = input.observedAt ?? Date.now();
const textSha256 = input.text ? conversationTextSha256(input.text) : null;
this.db.prepare(`
INSERT INTO conversation_events (
account_id, conversation_id, event_id, event_timestamp, direction,
actor, event_kind, text_sha256, source_reference,
attribution_confidence, observed_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(account_id, conversation_id, event_id) DO UPDATE SET
event_timestamp = excluded.event_timestamp,
direction = excluded.direction,
actor = CASE
WHEN conversation_events.attribution_confidence = 'exact' THEN conversation_events.actor
WHEN excluded.attribution_confidence = 'exact' THEN excluded.actor
WHEN conversation_events.attribution_confidence = 'fingerprint' THEN conversation_events.actor
ELSE excluded.actor
END,
event_kind = CASE
WHEN conversation_events.attribution_confidence = 'exact' THEN conversation_events.event_kind
WHEN excluded.attribution_confidence = 'exact' THEN excluded.event_kind
WHEN conversation_events.attribution_confidence = 'fingerprint' THEN conversation_events.event_kind
ELSE excluded.event_kind
END,
text_sha256 = COALESCE(excluded.text_sha256, conversation_events.text_sha256),
source_reference = COALESCE(excluded.source_reference, conversation_events.source_reference),
attribution_confidence = CASE
WHEN conversation_events.attribution_confidence = 'exact' OR excluded.attribution_confidence = 'exact' THEN 'exact'
WHEN conversation_events.attribution_confidence = 'fingerprint' OR excluded.attribution_confidence = 'fingerprint' THEN 'fingerprint'
ELSE 'inferred'
END,
observed_at = MAX(conversation_events.observed_at, excluded.observed_at)
`).run(
this.accountId,
input.conversationId,
input.eventId,
input.eventTimestamp,
input.direction,
input.actor,
input.eventKind,
textSha256,
input.sourceReference ?? null,
input.attributionConfidence,
observedAt,
);
return this.get(input.conversationId, input.eventId)!;
}
get(conversationId: string, eventId: string): ConversationEventRecord | undefined {
const row = this.db.prepare(`
SELECT * FROM conversation_events
WHERE account_id = ? AND conversation_id = ? AND event_id = ?
`).get(this.accountId, conversationId, eventId) as EventRow | undefined;
return row ? mapRow(row) : undefined;
}
listConversation(conversationId: string): ConversationEventRecord[] {
return (this.db.prepare(`
SELECT * FROM conversation_events
WHERE account_id = ? AND conversation_id = ?
ORDER BY event_timestamp ASC, event_id ASC
`).all(this.accountId, conversationId) as EventRow[]).map(mapRow);
}
countByConfidence(): Record<AttributionConfidence, number> {
const result: Record<AttributionConfidence, number> = { exact: 0, fingerprint: 0, inferred: 0 };
const rows = this.db.prepare(`
SELECT attribution_confidence, COUNT(*) AS total
FROM conversation_events WHERE account_id = ? GROUP BY attribution_confidence
`).all(this.accountId) as Array<{ attribution_confidence: AttributionConfidence; total: number }>;
for (const row of rows) result[row.attribution_confidence] = row.total;
return result;
}
}
interface EventRow {
account_id: string;
conversation_id: string;
event_id: string;
event_timestamp: number;
direction: 'inbound' | 'outbound';
actor: ConversationEventActor;
event_kind: ConversationEventKind;
text_sha256: string | null;
source_reference: string | null;
attribution_confidence: AttributionConfidence;
observed_at: number;
}
function mapRow(row: EventRow): ConversationEventRecord {
return {
accountId: row.account_id,
conversationId: row.conversation_id,
eventId: row.event_id,
eventTimestamp: row.event_timestamp,
direction: row.direction,
actor: row.actor,
eventKind: row.event_kind,
textSha256: row.text_sha256,
sourceReference: row.source_reference,
attributionConfidence: row.attribution_confidence,
observedAt: row.observed_at,
};
}
export function conversationTextSha256(text: string): string {
return createHash('sha256').update(text.trim()).digest('hex');
}