mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-30 21:49:18 +01:00
refactor(server): centralize ordered voice reply state reads
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
515e2625d0
commit
a159fadfa1
4 files changed
+72
-21
No files matched your search
@@ -1,5 +1,7 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
export const VOICE_REPLY_ACTION_KIND = "speko_voice_reply_push";
|
||||
|
||||
/** Plain voice text stays inside the provider's UTF-16 limit, without splitting
|
||||
* surrogate pairs. Whitespace-only chunks need no audible delivery. */
|
||||
export function splitSpekoReplyText(text: string): string[] {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { notifyVoiceRecovery } from "./voice-recovery-notifications.js";
|
||||
import { planVoiceReply } from "./voice-reply-policy.js";
|
||||
import { VOICE_REPLY_ACTION_KIND } from "./voice-reply-policy.js";
|
||||
import { readVoiceReplyState } from "./voice-reply-state.js";
|
||||
export { splitSpekoReplyText } from "./voice-reply-policy.js";
|
||||
import { and, asc, eq, gt, inArray, isNull, lt, sql } from "drizzle-orm";
|
||||
import { chatActions, chatEndpoints, chatPublications, chatVoiceReplies, chatVoiceSessions, type Db } from "@paperclipai/db";
|
||||
import { and, asc, eq, gt, inArray, isNull, lt } from "drizzle-orm";
|
||||
import { chatActions, chatEndpoints, chatVoiceReplies, chatVoiceSessions, type Db } from "@paperclipai/db";
|
||||
import { instanceSettingsService } from "../instance-settings.js";
|
||||
import { HttpError } from "../../errors.js";
|
||||
import { createSpekoProvider, SpekoProviderError } from "./speko-provider.js";
|
||||
@@ -10,7 +11,7 @@ import type { voiceSessionStore } from "./voice-session-store.js";
|
||||
|
||||
import { logger } from "../../middleware/logger.js";
|
||||
|
||||
const kind = "speko_voice_reply_push";
|
||||
const kind = VOICE_REPLY_ACTION_KIND;
|
||||
/** Existing action ledger is the push outbox. A committed dispatch intent is
|
||||
* never blindly resent after a crash or an ambiguous provider outcome. */
|
||||
export async function pushVoiceReplies(db: Db, options: {
|
||||
@@ -40,28 +41,16 @@ export async function pushVoiceReplies(db: Db, options: {
|
||||
const {session} = context;
|
||||
if (!session.providerSessionId) return null;
|
||||
await options.store.collectMissedReplies(tx, session);
|
||||
const [inFlight] = await tx.select({id: chatActions.id}).from(chatActions).where(and(eq(chatActions.companyId, session.companyId), eq(chatActions.endpointId, session.endpointId), eq(chatActions.kind, kind), eq(chatActions.status, "dispatching"), sql`${chatActions.payload}->>'sessionId' = ${session.id}`)).limit(1);
|
||||
if (inFlight) return null;
|
||||
const [row] = await tx.select({reply: chatVoiceReplies, payload: chatPublications.payload}).from(chatVoiceReplies)
|
||||
.innerJoin(chatPublications, and(eq(chatPublications.id, chatVoiceReplies.publicationId), eq(chatPublications.companyId, session.companyId)))
|
||||
.where(and(eq(chatVoiceReplies.companyId, session.companyId), eq(chatVoiceReplies.sessionId, session.id), isNull(chatVoiceReplies.deliveredAt),
|
||||
eq(chatPublications.endpointId, session.endpointId), eq(chatPublications.conversationId, session.conversationId), eq(chatPublications.issueId, session.issueId), eq(chatPublications.state, "published")))
|
||||
.orderBy(asc(chatVoiceReplies.cursor)).limit(1);
|
||||
// Questions need a hint that makes the phone agent retrieve the
|
||||
// authorized question IDs. Keep this reply pending until get_updates
|
||||
// presents it; never speak later answers over an unpresented question.
|
||||
const questionNotification = typeof row?.payload.interactionId === "string";
|
||||
if (!row || typeof row.payload.text !== "string" || !row.payload.text.trim()) return null;
|
||||
const saved = await tx.select().from(chatActions).where(and(eq(chatActions.companyId, session.companyId), eq(chatActions.endpointId, session.endpointId), eq(chatActions.kind, kind), sql`${chatActions.payload}->>'replyId' = ${row.reply.id}`)).for("update");
|
||||
const plan = planVoiceReply({ replyId: row.reply.id, text: row.payload.text, questionNotification, actions: saved, now: Date.now() });
|
||||
if (plan.kind !== "dispatch") return null;
|
||||
const state = await readVoiceReplyState(tx, session);
|
||||
if (state.kind !== "reply" || state.plan.kind !== "dispatch") return null;
|
||||
const { reply, questionNotification, plan } = state;
|
||||
const values = { status: "dispatching", result: null, updatedAt: new Date() };
|
||||
const action = plan.priorActionId
|
||||
? (await tx.update(chatActions).set(values).where(eq(chatActions.id, plan.priorActionId)).returning())[0]!
|
||||
: (await tx.insert(chatActions).values({ companyId: session.companyId, endpointId: session.endpointId, conversationId: session.conversationId, kind, providerActionId: plan.identity,
|
||||
payload: { sessionId: session.id, replyId: row.reply.id, publicationId: row.reply.publicationId, generation: session.generation, providerSessionId: session.providerSessionId,
|
||||
payload: { sessionId: session.id, replyId: reply.id, publicationId: reply.publicationId, generation: session.generation, providerSessionId: session.providerSessionId,
|
||||
questionNotification, partIndex: plan.partIndex, partCount: plan.partCount, textDigest: plan.textDigest }, ...values }).returning())[0]!;
|
||||
return { ...context, reply: row.reply, questionNotification, text: plan.text, partIndex: plan.partIndex, partCount: plan.partCount, action };
|
||||
return { ...context, reply, questionNotification, text: plan.text, partIndex: plan.partIndex, partCount: plan.partCount, action };
|
||||
}).catch(error => {
|
||||
if (error instanceof HttpError && [403,404].includes(error.status)) {
|
||||
logger.warn({event: "voice.reply.push.authorization_blocked", companyId: candidate.companyId,
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { and, asc, eq, isNull, sql } from "drizzle-orm";
|
||||
import { chatActions, chatPublications, chatVoiceReplies } from "@paperclipai/db";
|
||||
import type { VoiceSessionRow, VoiceTransaction } from "./voice-session-store.js";
|
||||
import { planVoiceReply, VOICE_REPLY_ACTION_KIND } from "./voice-reply-policy.js";
|
||||
|
||||
/** Read the ordered delivery barrier under the caller's existing session lock. */
|
||||
export async function readVoiceReplyState(
|
||||
tx: VoiceTransaction,
|
||||
session: Pick<VoiceSessionRow, "id" | "companyId" | "endpointId" | "conversationId" | "issueId">,
|
||||
) {
|
||||
const [inFlight] = await tx.select({ updatedAt: chatActions.updatedAt }).from(chatActions)
|
||||
.where(and(eq(chatActions.companyId, session.companyId), eq(chatActions.endpointId, session.endpointId),
|
||||
eq(chatActions.kind, VOICE_REPLY_ACTION_KIND), eq(chatActions.status, "dispatching"),
|
||||
sql`${chatActions.payload}->>'sessionId' = ${session.id}`))
|
||||
.orderBy(asc(chatActions.updatedAt)).limit(1);
|
||||
if (inFlight) return { kind: "in_flight" as const, retryAt: inFlight.updatedAt.getTime() + 30_001 };
|
||||
const [row] = await tx.select({ reply: chatVoiceReplies, payload: chatPublications.payload }).from(chatVoiceReplies)
|
||||
.innerJoin(chatPublications, and(eq(chatPublications.id, chatVoiceReplies.publicationId), eq(chatPublications.companyId, session.companyId)))
|
||||
.where(and(eq(chatVoiceReplies.companyId, session.companyId), eq(chatVoiceReplies.sessionId, session.id), isNull(chatVoiceReplies.deliveredAt),
|
||||
eq(chatPublications.endpointId, session.endpointId), eq(chatPublications.conversationId, session.conversationId),
|
||||
eq(chatPublications.issueId, session.issueId), eq(chatPublications.state, "published")))
|
||||
.orderBy(asc(chatVoiceReplies.cursor)).limit(1);
|
||||
if (!row || typeof row.payload.text !== "string" || !row.payload.text.trim()) return { kind: "blocked" as const };
|
||||
const questionNotification = typeof row.payload.interactionId === "string";
|
||||
const saved = await tx.select().from(chatActions).where(and(eq(chatActions.companyId, session.companyId),
|
||||
eq(chatActions.endpointId, session.endpointId), eq(chatActions.kind, VOICE_REPLY_ACTION_KIND),
|
||||
sql`${chatActions.payload}->>'replyId' = ${row.reply.id}`)).for("update");
|
||||
return { kind: "reply" as const, reply: row.reply, questionNotification,
|
||||
plan: planVoiceReply({ replyId: row.reply.id, text: row.payload.text, questionNotification, actions: saved, now: Date.now() }) };
|
||||
}
|
||||
Reference in new issue
Block a user