344 lines
9.9 KiB
TypeScript
344 lines
9.9 KiB
TypeScript
import { logger } from './logger.js';
|
|
import { getLatestOpenPairedTaskForChat, getMessagesSinceSeq } from './db.js';
|
|
import {
|
|
buildQueuedTurnDispatch,
|
|
executeBotOnlyPairedFollowUpAction,
|
|
} from './message-runtime-flow.js';
|
|
import { HUMAN_MESSAGE_DETECTED_CLOSE_REASON } from './message-close-reasons.js';
|
|
import {
|
|
advanceLastAgentCursor,
|
|
filterLoopingPairedBotMessages,
|
|
getProcessableMessages,
|
|
resolveCursorKey,
|
|
shouldSkipBotOnlyCollaboration,
|
|
} from './message-runtime-rules.js';
|
|
import { enqueuePairedFollowUpAfterEvent } from './message-runtime-follow-up.js';
|
|
import {
|
|
extractSessionCommand,
|
|
isSessionCommandAllowed,
|
|
} from './session-commands.js';
|
|
import { isSessionCommandSenderAllowed } from './config.js';
|
|
import { hasReviewerLease } from './service-routing.js';
|
|
import type { ExecuteTurnFn } from './message-runtime-types.js';
|
|
import type { ScheduledPairedFollowUpIntentKind } from './paired-follow-up-scheduler.js';
|
|
import type {
|
|
Channel,
|
|
NewMessage,
|
|
PairedTask,
|
|
PairedRoomRole,
|
|
RegisteredGroup,
|
|
} from './types.js';
|
|
|
|
type RuntimeLog = Pick<typeof logger, 'info' | 'debug'>;
|
|
|
|
function hasExternalHumanMessage(messages: NewMessage[]): boolean {
|
|
return messages.some(
|
|
(message) => message.is_from_me !== true && !message.is_bot_message,
|
|
);
|
|
}
|
|
|
|
export function enqueueGenericFollowUpAfterDeliveryRetry(args: {
|
|
chatJid: string;
|
|
runId: string;
|
|
deliveryRole: PairedRoomRole;
|
|
pendingTask: PairedTask | null | undefined;
|
|
workItemId: string | number;
|
|
log: RuntimeLog;
|
|
enqueueMessageCheck: () => void;
|
|
}): void {
|
|
const followUpResult = enqueuePairedFollowUpAfterEvent({
|
|
chatJid: args.chatJid,
|
|
runId: args.runId,
|
|
task: args.pendingTask,
|
|
source: 'delivery-retry',
|
|
completedRole: args.deliveryRole,
|
|
fallbackLastTurnOutputRole: args.deliveryRole,
|
|
enqueueMessageCheck: args.enqueueMessageCheck,
|
|
});
|
|
|
|
if (followUpResult.kind === 'none') {
|
|
args.log.info(
|
|
{
|
|
workItemId: args.workItemId,
|
|
chatJid: args.chatJid,
|
|
deliveryRole: args.deliveryRole,
|
|
pendingTaskStatus: followUpResult.taskStatus,
|
|
},
|
|
'No queued follow-up was required after delivery retry',
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (followUpResult.kind === 'message-check') {
|
|
return;
|
|
}
|
|
|
|
if (followUpResult.scheduled) {
|
|
args.log.info(
|
|
{
|
|
workItemId: args.workItemId,
|
|
chatJid: args.chatJid,
|
|
deliveryRole: args.deliveryRole,
|
|
taskId: followUpResult.taskId,
|
|
pendingTaskStatus: followUpResult.taskStatus,
|
|
taskStatus: followUpResult.taskStatus,
|
|
intentKind: followUpResult.intentKind,
|
|
},
|
|
'Queued paired follow-up after delivery retry',
|
|
);
|
|
return;
|
|
}
|
|
if (!followUpResult.scheduled) {
|
|
args.log.info(
|
|
{
|
|
workItemId: args.workItemId,
|
|
chatJid: args.chatJid,
|
|
deliveryRole: args.deliveryRole,
|
|
taskId: followUpResult.taskId,
|
|
pendingTaskStatus: followUpResult.taskStatus,
|
|
taskStatus: followUpResult.taskStatus,
|
|
intentKind: followUpResult.intentKind,
|
|
},
|
|
'Skipped duplicate paired follow-up enqueue after delivery retry while task state was unchanged',
|
|
);
|
|
}
|
|
}
|
|
|
|
export async function processQueuedGroupDispatch(args: {
|
|
chatJid: string;
|
|
group: RegisteredGroup;
|
|
channel: Channel;
|
|
processableGroupMessages: NewMessage[];
|
|
assistantName: string;
|
|
failureFinalText: string;
|
|
timezone: string;
|
|
lastAgentTimestamps: Record<string, string>;
|
|
saveState: () => void;
|
|
executeTurn: ExecuteTurnFn;
|
|
schedulePairedFollowUp: (
|
|
task: PairedTask | null | undefined,
|
|
intentKind: ScheduledPairedFollowUpIntentKind,
|
|
runId: string,
|
|
) => boolean;
|
|
enqueueMessageCheck: () => void;
|
|
sendQueuedMessage: (chatJid: string, text: string) => boolean;
|
|
closeStdin: (reason: string) => void;
|
|
labelPairedSenders: (chatJid: string, messages: NewMessage[]) => NewMessage[];
|
|
formatMessages: (messages: NewMessage[], timezone: string) => string;
|
|
}): Promise<void> {
|
|
const { chatJid, group, channel, processableGroupMessages } = args;
|
|
const loopPendingTask = hasReviewerLease(chatJid)
|
|
? getLatestOpenPairedTaskForChat(chatJid)
|
|
: null;
|
|
const loopCursorKey = resolveCursorKey(chatJid, loopPendingTask?.status);
|
|
const rawPendingMessages = getMessagesSinceSeq(
|
|
chatJid,
|
|
args.lastAgentTimestamps[loopCursorKey] || '0',
|
|
args.assistantName,
|
|
);
|
|
const pendingMessages = filterLoopingPairedBotMessages(
|
|
chatJid,
|
|
getProcessableMessages(chatJid, rawPendingMessages, channel),
|
|
args.failureFinalText,
|
|
);
|
|
const messagesToSend =
|
|
pendingMessages.length > 0 ? pendingMessages : processableGroupMessages;
|
|
const labeledMessagesToSend = args.labelPairedSenders(
|
|
chatJid,
|
|
messagesToSend,
|
|
);
|
|
const {
|
|
formatted,
|
|
botOnlyFollowUpAction,
|
|
isBotOnlyPairedFollowUp,
|
|
loopCursorKey: dispatchCursorKey,
|
|
endSeq,
|
|
} = buildQueuedTurnDispatch({
|
|
chatJid,
|
|
timezone: args.timezone,
|
|
loopPendingTask,
|
|
rawPendingMessages,
|
|
messagesToSend,
|
|
labeledMessagesToSend,
|
|
formatMessages: args.formatMessages,
|
|
});
|
|
|
|
const botOnlyFollowUpRunId = `loop-merge-ready-${Date.now().toString(36)}`;
|
|
if (
|
|
await executeBotOnlyPairedFollowUpAction({
|
|
action: botOnlyFollowUpAction,
|
|
chatJid,
|
|
group,
|
|
runId: botOnlyFollowUpRunId,
|
|
channel,
|
|
log: logger,
|
|
saveState: args.saveState,
|
|
lastAgentTimestamps: args.lastAgentTimestamps,
|
|
executeTurn: args.executeTurn,
|
|
schedulePairedFollowUp: (task, intentKind) =>
|
|
args.schedulePairedFollowUp(task, intentKind, botOnlyFollowUpRunId),
|
|
closeStdin: () => args.closeStdin('paired-pending-turn-follow-up'),
|
|
})
|
|
) {
|
|
return;
|
|
}
|
|
|
|
if (args.sendQueuedMessage(chatJid, formatted)) {
|
|
if (endSeq != null) {
|
|
advanceLastAgentCursor(
|
|
args.lastAgentTimestamps,
|
|
args.saveState,
|
|
chatJid,
|
|
endSeq,
|
|
dispatchCursorKey,
|
|
);
|
|
}
|
|
logger.debug(
|
|
{
|
|
transition: 'typing:on',
|
|
source: 'follow-up-queued',
|
|
chatJid,
|
|
group: group.name,
|
|
groupFolder: group.folder,
|
|
endSeq: endSeq ?? null,
|
|
suppressed: isBotOnlyPairedFollowUp,
|
|
},
|
|
'Typing indicator transition',
|
|
);
|
|
if (!isBotOnlyPairedFollowUp) {
|
|
await channel
|
|
.setTyping?.(chatJid, true)
|
|
?.catch((err) =>
|
|
logger.warn({ chatJid, err }, 'Failed to set typing indicator'),
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
|
|
args.enqueueMessageCheck();
|
|
}
|
|
|
|
export async function processLoopGroupMessages(args: {
|
|
chatJid: string;
|
|
group: RegisteredGroup;
|
|
groupMessages: NewMessage[];
|
|
channel: Channel;
|
|
assistantName: string;
|
|
failureFinalText: string;
|
|
triggerPattern: RegExp;
|
|
hasImplicitContinuationWindow: (
|
|
chatJid: string,
|
|
messages: NewMessage[],
|
|
) => boolean;
|
|
lastAgentTimestamps: Record<string, string>;
|
|
saveState: () => void;
|
|
timezone: string;
|
|
executeTurn: ExecuteTurnFn;
|
|
schedulePairedFollowUp: (
|
|
task: PairedTask | null | undefined,
|
|
intentKind: ScheduledPairedFollowUpIntentKind,
|
|
runId: string,
|
|
) => boolean;
|
|
enqueueMessageCheck: () => void;
|
|
sendQueuedMessage: (chatJid: string, text: string) => boolean;
|
|
closeStdin: (reason: string) => void;
|
|
isRunningMessageTurn: (chatJid: string) => boolean;
|
|
labelPairedSenders: (chatJid: string, messages: NewMessage[]) => NewMessage[];
|
|
formatMessages: (messages: NewMessage[], timezone: string) => string;
|
|
}): Promise<void> {
|
|
const { chatJid, group, groupMessages, channel } = args;
|
|
const isMainGroup = group.isMain === true;
|
|
const processableGroupMessages = getProcessableMessages(
|
|
chatJid,
|
|
groupMessages,
|
|
channel,
|
|
);
|
|
|
|
if (processableGroupMessages.length === 0) {
|
|
const lastIgnored = groupMessages[groupMessages.length - 1];
|
|
if (lastIgnored?.seq != null) {
|
|
advanceLastAgentCursor(
|
|
args.lastAgentTimestamps,
|
|
args.saveState,
|
|
chatJid,
|
|
lastIgnored.seq,
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (shouldSkipBotOnlyCollaboration(chatJid, processableGroupMessages)) {
|
|
const lastIgnored =
|
|
processableGroupMessages[processableGroupMessages.length - 1];
|
|
if (lastIgnored?.seq != null) {
|
|
advanceLastAgentCursor(
|
|
args.lastAgentTimestamps,
|
|
args.saveState,
|
|
chatJid,
|
|
lastIgnored.seq,
|
|
);
|
|
}
|
|
logger.info(
|
|
{ chatJid, group: group.name, groupFolder: group.folder },
|
|
'Bot-collaboration timeout: no recent human message, skipping',
|
|
);
|
|
return;
|
|
}
|
|
|
|
const loopCmdMsg = groupMessages.find(
|
|
(msg) => extractSessionCommand(msg.content, args.triggerPattern) !== null,
|
|
);
|
|
|
|
if (loopCmdMsg) {
|
|
if (
|
|
isSessionCommandAllowed(
|
|
isMainGroup,
|
|
loopCmdMsg.is_from_me === true,
|
|
isSessionCommandSenderAllowed(loopCmdMsg.sender),
|
|
)
|
|
) {
|
|
args.closeStdin('session-command-detected');
|
|
}
|
|
args.enqueueMessageCheck();
|
|
return;
|
|
}
|
|
|
|
if (hasExternalHumanMessage(processableGroupMessages)) {
|
|
const interruptedActiveRun = args.isRunningMessageTurn(chatJid);
|
|
if (interruptedActiveRun) {
|
|
args.closeStdin(HUMAN_MESSAGE_DETECTED_CLOSE_REASON);
|
|
}
|
|
args.enqueueMessageCheck();
|
|
logger.info(
|
|
{
|
|
chatJid,
|
|
group: group.name,
|
|
groupFolder: group.folder,
|
|
messageCount: processableGroupMessages.length,
|
|
interruptedActiveRun,
|
|
},
|
|
'Queued human message for a fresh turn instead of piping into active agent',
|
|
);
|
|
return;
|
|
}
|
|
|
|
await processQueuedGroupDispatch({
|
|
chatJid,
|
|
group,
|
|
channel,
|
|
processableGroupMessages,
|
|
assistantName: args.assistantName,
|
|
failureFinalText: args.failureFinalText,
|
|
timezone: args.timezone,
|
|
lastAgentTimestamps: args.lastAgentTimestamps,
|
|
saveState: args.saveState,
|
|
executeTurn: args.executeTurn,
|
|
schedulePairedFollowUp: args.schedulePairedFollowUp,
|
|
enqueueMessageCheck: args.enqueueMessageCheck,
|
|
sendQueuedMessage: args.sendQueuedMessage,
|
|
closeStdin: args.closeStdin,
|
|
labelPairedSenders: args.labelPairedSenders,
|
|
formatMessages: args.formatMessages,
|
|
});
|
|
}
|