Pause automation after manual Reddit replies

This commit is contained in:
jjbrunton committed 2026-08-09 19:30:16 +01:00
1 parent bf474dfe10
commit 84a133451f
5 files changed
+175 -6

No files matched your search

+53 -2
View File
@@ -189,6 +189,22 @@ export class ConversationAutomationService {
this.states.save(conversationId, state);
}
const incomingKey = messageKey(latest);
const mediaAttribution = this.mediaExchange?.getOutboundAttribution?.(conversationId);
if (state.automation.status === 'active'
&& hasUnattributedSelfMessage(
messages,
opener,
state.automation.aiMessageFingerprints,
mediaAttribution?.eventIds,
mediaAttribution?.texts,
)) {
state.automation.status = 'user-overridden';
state.automation.handoffReason = 'A manual reply was observed in this conversation.';
this.states.save(conversationId, state);
this.claims.markSuperseded?.(conversationId, incomingKey);
this.logger.info(`[automation] Paused u/${conversation.username}; an operator-authored message was observed`);
return null;
}
const existingClaim = this.claims.getClaim?.(conversationId, incomingKey);
if (existingClaim?.status === 'pending') {
const observedOutbound = messages.some((message) => message.isSelf && message.timestamp > latest.timestamp);
@@ -398,9 +414,17 @@ export class ConversationAutomationService {
const messages = conversation.messages ?? [];
const incomingIndex = messages.findIndex((message) => messageKey(message) === claim.incomingKey);
if (incomingIndex < 0) continue;
if (!messages.slice(incomingIndex + 1).some((message) => message.isSelf)) continue;
if (this.claims.markSent(claim.conversationId, claim.incomingKey)) {
const laterSelfMessages = messages.slice(incomingIndex + 1).filter((message) => message.isSelf);
if (laterSelfMessages.length === 0) continue;
const expectedFingerprint = claim.replyText
? conversationMessageFingerprint(claim.replyText)
: null;
const observedClaimedReply = expectedFingerprint !== null
&& laterSelfMessages.some((message) => conversationMessageFingerprint(message.text) === expectedFingerprint);
if (observedClaimedReply && this.claims.markSent(claim.conversationId, claim.incomingKey)) {
this.logger.info(`[automation] Reconciled interrupted reply for u/${conversation.username} from Matrix history`);
} else if (!observedClaimedReply && this.claims.markSuperseded?.(claim.conversationId, claim.incomingKey)) {
this.logger.info(`[automation] Superseded queued reply for u/${conversation.username}; an operator-authored message was observed`);
}
}
}
@@ -632,6 +656,33 @@ function messageKey(message: ChatMessage): string {
return message.eventId ?? `${message.timestamp}:${message.sender}:${message.text}`;
}
function hasUnattributedSelfMessage(
messages: readonly ChatMessage[],
opener: SentMessageRecord,
automatedFingerprints: readonly string[],
automatedEventIds: readonly string[] = [],
automatedTexts: readonly string[] = [],
): boolean {
const knownEventIds = new Set(automatedEventIds);
const knownFingerprints = new Map<string, number>();
const addKnown = (fingerprint: string) => {
knownFingerprints.set(fingerprint, (knownFingerprints.get(fingerprint) ?? 0) + 1);
};
addKnown(conversationMessageFingerprint(opener.message));
for (const fingerprint of automatedFingerprints) addKnown(fingerprint);
for (const text of automatedTexts) addKnown(conversationMessageFingerprint(text));
for (const message of messages) {
if (!message.isSelf || message.timestamp < opener.sentAt) continue;
if (message.eventId && knownEventIds.has(message.eventId)) continue;
const fingerprint = conversationMessageFingerprint(message.text);
const remaining = knownFingerprints.get(fingerprint) ?? 0;
if (remaining === 0) return true;
knownFingerprints.set(fingerprint, remaining - 1);
}
return false;
}
function replyPlan(state: ConversationState): { isLegacy: boolean; isHybridMediaAsk: boolean } {
const isLegacy = state.automation.policyMode === 'legacy';
const isHybridMediaAsk = state.automation.policyMode === 'r4r-media'
+16 -1
View File
@@ -14,6 +14,13 @@ export interface MediaExchangeChat {
sendChatMessage(roomId: string, message: string, txnId?: string): Promise<boolean>;
}
export interface MediaExchangeOutboundAttribution {
eventIds: string[];
texts: string[];
}
const RECIPROCAL_MESSAGE = 'Your turn — send me one back if you’re comfortable.';
export type MediaExchangeResult =
| { kind: 'not-requested' }
| { kind: 'dry-run'; requestKind: MediaRequestKind }
@@ -40,6 +47,14 @@ export class MediaLibraryExchangeService {
);
}
getOutboundAttribution(conversationId: string): MediaExchangeOutboundAttribution {
const sends = this.repository.listConversationSends(this.accountId, conversationId);
return {
eventIds: sends.flatMap((send) => send.matrixMediaEventId ? [send.matrixMediaEventId] : []),
texts: sends.flatMap((send) => send.matrixTextEventId ? [RECIPROCAL_MESSAGE] : []),
};
}
async handle(
conversationId: string,
incomingKey: string,
@@ -103,7 +118,7 @@ export class MediaLibraryExchangeService {
? true
: await this.chat.sendChatMessage(
conversationId,
'Your turn — send me one back if you’re comfortable.',
RECIPROCAL_MESSAGE,
`rbot-library-reciprocal-${send.id}`,
);
if (!textSent) {