Files
pi-config/extensions/pi-intercom/reply-tracker.ts

103 lines
2.8 KiB
TypeScript

import type { Message, SessionInfo } from "./types.ts";
export interface IntercomContext {
from: SessionInfo;
message: Message;
receivedAt: number;
}
function matchesPendingSender(context: IntercomContext, to: string): boolean {
if (context.from.id === to) {
return true;
}
return context.from.name?.toLowerCase() === to.toLowerCase();
}
export class ReplyTracker {
private readonly pendingAsks = new Map<string, IntercomContext>();
private readonly pendingTurnContexts: IntercomContext[] = [];
private currentTurnContext: IntercomContext | null = null;
constructor(private readonly askTimeoutMs = 10 * 60 * 1000) {}
recordIncomingMessage(from: SessionInfo, message: Message, receivedAt = Date.now()): IntercomContext {
const context = { from, message, receivedAt };
if (message.expectsReply) {
this.pendingAsks.set(message.id, context);
}
return context;
}
queueTurnContext(context: IntercomContext): void {
this.pendingTurnContexts.push(context);
}
beginTurn(now = Date.now()): void {
this.pruneExpired(now);
this.currentTurnContext = this.pendingTurnContexts.shift() ?? null;
}
endTurn(): void {
this.currentTurnContext = null;
}
reset(): void {
this.pendingAsks.clear();
this.pendingTurnContexts.length = 0;
this.currentTurnContext = null;
}
resolveReplyTarget(options: { to?: string }, now = Date.now()): IntercomContext {
this.pruneExpired(now);
if (this.currentTurnContext) {
return this.currentTurnContext;
}
const pending = Array.from(this.pendingAsks.values());
if (pending.length === 1) {
return pending[0]!;
}
if (options.to) {
const matches = pending.filter((context) => matchesPendingSender(context, options.to!));
if (matches.length === 1) {
return matches[0]!;
}
if (matches.length > 1) {
throw new Error(`Multiple pending asks from \"${options.to}\" — use the sender session ID instead.`);
}
if (pending.length > 1) {
throw new Error(`No pending ask from \"${options.to}\"`);
}
}
if (pending.length === 0) {
throw new Error("No active intercom context to reply to");
}
throw new Error("Multiple pending asks — specify `to`");
}
markReplied(replyTo: string): void {
this.pendingAsks.delete(replyTo);
if (this.currentTurnContext?.message.id === replyTo) {
this.currentTurnContext = null;
}
}
listPending(now = Date.now()): IntercomContext[] {
this.pruneExpired(now);
return Array.from(this.pendingAsks.values()).sort((a, b) => a.receivedAt - b.receivedAt);
}
private pruneExpired(now: number): void {
for (const [messageId, context] of this.pendingAsks) {
if (now - context.receivedAt > this.askTimeoutMs) {
this.pendingAsks.delete(messageId);
}
}
}
}