mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-06 05:56:19 +01:00
## Thinking Path > - Paperclip manages AI agents and their work. > - Chat channels connect that work to provider conversations. > - One shared service contains callback, delivery, reaction, and setup logic. > - These responsibilities have distinct contracts and existing public-path tests. > - This PR moves ten cohesive responsibilities into focused modules. > - The service retains shared runtime ownership and scheduling. > - Provider behavior is easier to locate and review. ## Linked Issues or Issue Description Refs #15995, #16026, #16027, #16030, #16031, #16035, #16037, #16038, #16041, #16043. This PR consolidates the reviewed extraction stack. The plan is `doc/plans/2026-10-12-chat-channel-delivery-boundaries-refactor.md`. ## What Changed - Move Slack callback verification into the Slack folder. - Move Discord Gateway events and root-thread admission/recovery into the Discord folder. - Extract durable receipt reactions and inbound reaction admission/replay. - Extract inbound attachment ingestion and outbound integrity-checked hydration. Preserve the public hydration export. - Extract durable provider effects. Keep Telegram callback notices in the Telegram folder. - Extract conversation/activity reads and setup qualification. Keep Slack and Teams projections in their channel folders. - Preserve the merged managed Slack setup behavior, shared workers, credential leases, and transaction boundaries. - Reduce `chat-channels.ts` from 26,493 lines / 1,048,769 bytes to 21,460 lines / 862,284 bytes. This removes 5,033 lines from the service. ## Verification - Each extraction passed its selected existing tests before and after through public service paths. - On updated master, all 55 selected Slack cases passed before extraction. All 194 selected chat/managed-Slack cases passed on the final stack tip. - `pnpm -r typecheck`, `pnpm build`, module-boundary checks, and whitespace checks passed on that tip. - The normalized syntax audit verifies 69 moved declarations and all retained statements, including their relative order, against updated master. - The eight earlier stack merges retain the exact validated repository tree. The combined root equals the validated tip merged with current master. All chat-channel files remain byte-identical to the validated tip. Current combined-root CI and review are pending. - Full local verification remains unverified. The unchanged `workspace-git-snapshot-streaming.test.ts:56` stress test timed out at 300,000 ms in a clean run and again in isolation. The full run was stopped after the reproduced failure. No timeout or assertion was weakened. - These checks use assembled services, real local databases, and provider fixtures. They do not prove live third-party execution. Reviewers can run the public-path suites: ```sh pnpm exec vitest run server/src/__tests__/chat-channels.integration.test.ts server/src/services/chat-outbound-attachment-hydration.test.ts server/src/__tests__/slack-development-manager.test.ts ``` ## Risks - Factory wiring must preserve live values, shared state, and initialization order. Identity-link construction retains deferred access to the provider-effects callback. - Credential fences, authorization transactions, and delivery settlement must keep their original ordering. Syntax parity and public-path integration cases cover these boundaries. - Full local verification has the recorded Git-streaming timeout. The combined PR must pass master’s required CI before merging into master. ## Model Used OpenAI Codex, GPT-6 family, with reasoning and repository tools. The exact runtime model ID and context-window size are not exposed in this session. No separate agent was used. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [ ] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
571 lines
21 KiB
TypeScript
571 lines
21 KiB
TypeScript
import type { EndpointRow, DbTransaction, DbOrTransaction, RuntimeContext, LifecycleRuntimeFence, InboundRuntimeContext } from "../runtime-types.js";
|
|
|
|
import { notifyChatDeliveryWork } from "../../chat-work-notifications.js";
|
|
|
|
import type { ChatEndpointRuntime } from "../../voice/voice-runtime.js";
|
|
|
|
import { chatCredentialMutationLease } from "../../chat-credential-mutation-lease.js";
|
|
|
|
import {
|
|
createHash,
|
|
} from "node:crypto";
|
|
import {
|
|
and,
|
|
eq,
|
|
inArray,
|
|
sql,
|
|
} from "drizzle-orm";
|
|
|
|
import type { Db } from "@paperclipai/db";
|
|
|
|
import {
|
|
chatConversations,
|
|
chatDeliveries,
|
|
chatEndpointResources,
|
|
chatEndpoints,
|
|
chatExternalPrincipals,
|
|
companyMemberships,
|
|
} from "@paperclipai/db";
|
|
|
|
import {
|
|
conflict,
|
|
} from "../../../errors.js";
|
|
import { logger } from "../../../middleware/logger.js";
|
|
|
|
import {
|
|
DiscordAdapterCompatibilityError,
|
|
type ChatSdkMessageCallbackEvent,
|
|
type DiscordRootMentionAdmissionEvent,
|
|
} from "../../chat-sdk-runtime.js";
|
|
|
|
import { classifyChatPublicationError } from "../../chat-publication-errors.js";
|
|
|
|
import type {
|
|
Message,
|
|
Thread,
|
|
} from "chat";
|
|
|
|
type DeliveryRow = typeof chatDeliveries.$inferSelect;
|
|
|
|
type DiscordRootThreadsOptions = {
|
|
ensureDiscordGatewayRuntimeIsCurrent: (endpointId: string, context: RuntimeContext) => Promise<boolean>;
|
|
withCredentialMutationLease: ReturnType<typeof chatCredentialMutationLease>;
|
|
runtimeCallbackEndpoint: (tx: DbTransaction, endpointId: string, context: LifecycleRuntimeFence, statuses: EndpointRow["status"][]) => Promise<EndpointRow | null>;
|
|
lockCurrentPrincipalAuthorization: (tx: DbOrTransaction, endpoint: EndpointRow, principalId: string) => Promise<{ allowed: boolean; linkedDenied: boolean }>;
|
|
processMessage: (endpoint: EndpointRow, thread: Thread, message: Message, trigger: ChatSdkMessageCallbackEvent["trigger"], ingressOnly?: boolean, recoveredProviderUrl?: string | null, runtimeContext?: InboundRuntimeContext, providerUpdateId?: number, admittedDeliveryId?: string | null, receiptReactionSupported?: boolean, preauthorizedUserId?: string | null, deferDrainUntilFollowup?: boolean) => Promise<void>;
|
|
redactError: (error: unknown) => string;
|
|
scheduleConversationDrain: (endpointId: string, threadId: string) => void;
|
|
lifecycleRuntimeFence: (delivery: DeliveryRow) => LifecycleRuntimeFence | null;
|
|
runtimeFor: (endpoint: EndpointRow) => Promise<ChatEndpointRuntime>;
|
|
DISCORD_ROOT_THREAD_CONFIRMATION_DELAY_MS: number;
|
|
discordRootThreadTransportBarrier?: () => Promise<void>;
|
|
};
|
|
|
|
export function createDiscordRootThreads(db: Db, options: DiscordRootThreadsOptions) {
|
|
const { ensureDiscordGatewayRuntimeIsCurrent, withCredentialMutationLease, runtimeCallbackEndpoint, lockCurrentPrincipalAuthorization, processMessage, redactError, scheduleConversationDrain, lifecycleRuntimeFence, runtimeFor, DISCORD_ROOT_THREAD_CONFIRMATION_DELAY_MS } = options;
|
|
|
|
|
|
async function admitDiscordRootMention(
|
|
configuredEndpoint: EndpointRow,
|
|
event: DiscordRootMentionAdmissionEvent,
|
|
context: RuntimeContext,
|
|
): Promise<boolean> {
|
|
if (
|
|
!(await ensureDiscordGatewayRuntimeIsCurrent(
|
|
configuredEndpoint.id,
|
|
context,
|
|
))
|
|
) {
|
|
return false;
|
|
}
|
|
return withCredentialMutationLease(
|
|
configuredEndpoint,
|
|
async (credentialLease) => {
|
|
const threadId = `discord:${event.guildId}:${event.channelId}:${event.messageId}`;
|
|
const providerEventId = `${threadId}:${event.messageId}`;
|
|
const allowed = await db.transaction(async (tx) => {
|
|
await credentialLease.assertOwned(tx);
|
|
const endpoint = await runtimeCallbackEndpoint(
|
|
tx,
|
|
configuredEndpoint.id,
|
|
context,
|
|
["verifying", "active"],
|
|
);
|
|
const existing = await tx
|
|
.select({ state: chatDeliveries.state })
|
|
.from(chatDeliveries)
|
|
.where(
|
|
and(
|
|
eq(chatDeliveries.endpointId, configuredEndpoint.id),
|
|
eq(chatDeliveries.providerEventId, providerEventId),
|
|
),
|
|
)
|
|
.then((rows) => rows[0] ?? null);
|
|
if (
|
|
existing &&
|
|
["processed", "filtered", "failed"].includes(existing.state)
|
|
)
|
|
return false;
|
|
|
|
let reason: string | null = null;
|
|
if (!endpoint || endpoint.providerAccountId !== event.guildId) {
|
|
reason = "Connection is not active";
|
|
}
|
|
const resource = endpoint
|
|
? await tx
|
|
.select()
|
|
.from(chatEndpointResources)
|
|
.where(
|
|
and(
|
|
eq(chatEndpointResources.endpointId, endpoint.id),
|
|
eq(chatEndpointResources.type, "channel"),
|
|
eq(
|
|
chatEndpointResources.providerResourceId,
|
|
event.channelId,
|
|
),
|
|
),
|
|
)
|
|
.for("update")
|
|
.then((rows) => rows[0] ?? null)
|
|
: null;
|
|
const enabledCount =
|
|
endpoint?.status === "verifying"
|
|
? await tx
|
|
.select({ count: sql<number>`count(*)::int` })
|
|
.from(chatEndpointResources)
|
|
.where(
|
|
and(
|
|
eq(chatEndpointResources.endpointId, endpoint.id),
|
|
eq(chatEndpointResources.type, "channel"),
|
|
eq(chatEndpointResources.enabled, true),
|
|
eq(chatEndpointResources.availability, "available"),
|
|
),
|
|
)
|
|
.then((rows) => rows[0]?.count ?? 0)
|
|
: 0;
|
|
const setupDestination =
|
|
endpoint?.status === "verifying" &&
|
|
enabledCount === 0 &&
|
|
resource?.availability === "available";
|
|
if (
|
|
!reason &&
|
|
!(
|
|
endpoint?.allowGroupChats &&
|
|
resource?.availability === "available" &&
|
|
(resource.enabled || setupDestination)
|
|
)
|
|
) {
|
|
reason = "Destination is not enabled in Paperclip";
|
|
}
|
|
|
|
if (!reason && endpoint) {
|
|
const principal = await tx
|
|
.select({ id: chatExternalPrincipals.id })
|
|
.from(chatExternalPrincipals)
|
|
.where(
|
|
and(
|
|
eq(chatExternalPrincipals.companyId, endpoint.companyId),
|
|
eq(chatExternalPrincipals.provider, "discord"),
|
|
eq(chatExternalPrincipals.providerAccountId, event.guildId),
|
|
eq(chatExternalPrincipals.externalId, event.userId),
|
|
),
|
|
)
|
|
.then((rows) => rows[0] ?? null);
|
|
let allowed: boolean;
|
|
let linkedDenied = false;
|
|
if (principal) {
|
|
const authorization = await lockCurrentPrincipalAuthorization(
|
|
tx,
|
|
endpoint,
|
|
principal.id,
|
|
);
|
|
allowed = authorization.allowed;
|
|
linkedDenied = authorization.linkedDenied;
|
|
} else if (!endpoint.allowUnlinkedPeople) {
|
|
allowed = false;
|
|
} else if (!endpoint.sponsorUserId) {
|
|
allowed = true;
|
|
} else {
|
|
const sponsor = await tx
|
|
.select({
|
|
status: companyMemberships.status,
|
|
membershipRole: companyMemberships.membershipRole,
|
|
})
|
|
.from(companyMemberships)
|
|
.where(
|
|
and(
|
|
eq(companyMemberships.companyId, endpoint.companyId),
|
|
eq(companyMemberships.principalType, "user"),
|
|
eq(companyMemberships.principalId, endpoint.sponsorUserId),
|
|
),
|
|
)
|
|
.for("update")
|
|
.then((rows) => rows[0] ?? null);
|
|
allowed =
|
|
sponsor?.status === "active" &&
|
|
sponsor.membershipRole !== "viewer";
|
|
}
|
|
if (!allowed) {
|
|
reason = linkedDenied
|
|
? "Linked Paperclip account is not currently permitted"
|
|
: endpoint.allowUnlinkedPeople
|
|
? "Endpoint sponsor can no longer authorize external guests"
|
|
: "External identity must be linked to a Paperclip account";
|
|
}
|
|
}
|
|
if (!reason) {
|
|
await credentialLease.assertOwned(tx);
|
|
return true;
|
|
}
|
|
|
|
const filteredAt = new Date();
|
|
const normalizedEvent = {
|
|
providerEventId,
|
|
kind: "mention",
|
|
trigger: "mention",
|
|
resource: {
|
|
type: "channel",
|
|
providerResourceId: event.channelId,
|
|
},
|
|
conversation: { externalThreadId: threadId },
|
|
message: { providerMessageId: event.messageId },
|
|
filtering: { contentRetained: false, providerThreadCreated: false },
|
|
};
|
|
await notifyChatDeliveryWork(tx);
|
|
await tx
|
|
.insert(chatDeliveries)
|
|
.values({
|
|
companyId: configuredEndpoint.companyId,
|
|
endpointId: configuredEndpoint.id,
|
|
providerEventId,
|
|
deduplicationKey: createHash("sha256")
|
|
.update(providerEventId)
|
|
.digest("hex"),
|
|
eventKind: "mention",
|
|
normalizedEvent,
|
|
state: "filtered",
|
|
redactedError: reason,
|
|
processedAt: filteredAt,
|
|
})
|
|
.onConflictDoUpdate({
|
|
target: [
|
|
chatDeliveries.endpointId,
|
|
chatDeliveries.providerEventId,
|
|
],
|
|
set: {
|
|
normalizedEvent,
|
|
principalId: null,
|
|
state: "filtered",
|
|
nextAttemptAt: null,
|
|
redactedError: reason,
|
|
processedAt: filteredAt,
|
|
updatedAt: filteredAt,
|
|
},
|
|
});
|
|
await tx
|
|
.update(chatEndpoints)
|
|
.set({ lastEventAt: filteredAt, updatedAt: filteredAt })
|
|
.where(eq(chatEndpoints.id, configuredEndpoint.id));
|
|
await credentialLease.assertOwned(tx);
|
|
return false;
|
|
});
|
|
if (!allowed || !event.message || !context.endpointRuntime) {
|
|
return false;
|
|
}
|
|
const thread = context.endpointRuntime.thread(event.threadId);
|
|
await processMessage(
|
|
configuredEndpoint,
|
|
thread,
|
|
event.message,
|
|
"mention",
|
|
true,
|
|
null,
|
|
context,
|
|
undefined,
|
|
null,
|
|
true,
|
|
undefined,
|
|
true,
|
|
);
|
|
const staged = await db
|
|
.select({ state: chatDeliveries.state })
|
|
.from(chatDeliveries)
|
|
.where(
|
|
and(
|
|
eq(chatDeliveries.endpointId, configuredEndpoint.id),
|
|
eq(chatDeliveries.providerEventId, providerEventId),
|
|
),
|
|
)
|
|
.then((rows) => rows[0] ?? null);
|
|
if (
|
|
!staged ||
|
|
!["received", "retry", "processing"].includes(staged.state)
|
|
) {
|
|
return false;
|
|
}
|
|
|
|
await options.discordRootThreadTransportBarrier?.();
|
|
await credentialLease.assertOwned();
|
|
if (
|
|
!(await ensureDiscordGatewayRuntimeIsCurrent(
|
|
configuredEndpoint.id,
|
|
context,
|
|
))
|
|
) {
|
|
return false;
|
|
}
|
|
try {
|
|
await context.endpointRuntime.ensureDiscordRootThread({
|
|
channelId: event.channelId,
|
|
content: event.message.text,
|
|
messageId: event.messageId,
|
|
});
|
|
} catch (error) {
|
|
// The durable provisional receipt owns recovery. Do not let a
|
|
// transient REST failure escape the Gateway event handler or ask the
|
|
// adapter to create the same thread outside Paperclip's lifecycle
|
|
// fence.
|
|
logger.warn(
|
|
{
|
|
endpointId: configuredEndpoint.id,
|
|
error: redactError(error),
|
|
providerEventId,
|
|
},
|
|
"Discord root thread creation will be retried from the durable receipt",
|
|
);
|
|
return false;
|
|
}
|
|
const promoted = await db.transaction(async (tx) => {
|
|
await credentialLease.assertOwned(tx);
|
|
const current = await runtimeCallbackEndpoint(
|
|
tx,
|
|
configuredEndpoint.id,
|
|
context,
|
|
["verifying", "active"],
|
|
);
|
|
if (!current) return false;
|
|
await notifyChatDeliveryWork(tx);
|
|
const [row] = await tx
|
|
.update(chatDeliveries)
|
|
.set({
|
|
state: "received",
|
|
normalizedEvent: sql`${chatDeliveries.normalizedEvent} - 'providerThreadPending'`,
|
|
nextAttemptAt: null,
|
|
redactedError: null,
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(
|
|
and(
|
|
eq(chatDeliveries.endpointId, configuredEndpoint.id),
|
|
eq(chatDeliveries.providerEventId, providerEventId),
|
|
eq(chatDeliveries.state, "retry"),
|
|
sql`${chatDeliveries.normalizedEvent}->>'providerThreadPending' = 'true'`,
|
|
),
|
|
)
|
|
.returning({ id: chatDeliveries.id });
|
|
await credentialLease.assertOwned(tx);
|
|
return Boolean(row);
|
|
});
|
|
if (promoted)
|
|
scheduleConversationDrain(configuredEndpoint.id, threadId);
|
|
// Paperclip created the provider thread inside its credential/reach
|
|
// lease. Returning false prevents the adapter from creating it again;
|
|
// the durable drain owns the remaining task mutation.
|
|
return false;
|
|
},
|
|
);
|
|
}
|
|
|
|
function discordRootThreadPending(
|
|
delivery: Pick<DeliveryRow, "normalizedEvent">,
|
|
): boolean {
|
|
return (
|
|
Boolean(delivery.normalizedEvent) &&
|
|
typeof delivery.normalizedEvent === "object" &&
|
|
!Array.isArray(delivery.normalizedEvent) &&
|
|
(delivery.normalizedEvent as { providerThreadPending?: unknown })
|
|
.providerThreadPending === true
|
|
);
|
|
}
|
|
|
|
async function reconcileDiscordRootThread(
|
|
endpoint: EndpointRow,
|
|
delivery: DeliveryRow,
|
|
threadId: string,
|
|
): Promise<boolean> {
|
|
try {
|
|
const normalized = delivery.normalizedEvent as {
|
|
message?: { providerMessageId?: unknown; text?: unknown };
|
|
resource?: { providerResourceId?: unknown };
|
|
};
|
|
const discordThreadParts = threadId.split(":");
|
|
const channelId =
|
|
discordThreadParts[0] === "discord" && discordThreadParts.length >= 4
|
|
? discordThreadParts[2]
|
|
: normalized.resource?.providerResourceId;
|
|
const messageId = normalized.message?.providerMessageId;
|
|
if (typeof channelId !== "string" || typeof messageId !== "string") {
|
|
throw new DiscordAdapterCompatibilityError(
|
|
"the provisional root receipt is missing its channel or message id",
|
|
);
|
|
}
|
|
const runtimeFence = lifecycleRuntimeFence(delivery);
|
|
if (!runtimeFence) {
|
|
throw new DiscordAdapterCompatibilityError(
|
|
"the provisional root receipt is missing its runtime fence",
|
|
);
|
|
}
|
|
return await withCredentialMutationLease(
|
|
endpoint,
|
|
async (credentialLease) => {
|
|
const currentEndpoint = await db.transaction(async (tx) => {
|
|
await credentialLease.assertOwned(tx);
|
|
const current = await runtimeCallbackEndpoint(
|
|
tx,
|
|
endpoint.id,
|
|
runtimeFence,
|
|
["verifying", "active"],
|
|
);
|
|
await credentialLease.assertOwned(tx);
|
|
return current;
|
|
});
|
|
if (!currentEndpoint) {
|
|
throw conflict(
|
|
"Discord root thread credentials or connection state changed before recovery",
|
|
{ code: "chat_endpoint_runtime_superseded" },
|
|
);
|
|
}
|
|
const endpointRuntime = await runtimeFor(currentEndpoint);
|
|
await credentialLease.assertOwned();
|
|
await endpointRuntime.ensureDiscordRootThread({
|
|
channelId,
|
|
messageId,
|
|
content:
|
|
typeof normalized.message?.text === "string"
|
|
? normalized.message.text
|
|
: "Paperclip task",
|
|
});
|
|
return await db.transaction(async (tx) => {
|
|
await credentialLease.assertOwned(tx);
|
|
const current = await runtimeCallbackEndpoint(
|
|
tx,
|
|
endpoint.id,
|
|
runtimeFence,
|
|
["verifying", "active"],
|
|
);
|
|
if (!current) return false;
|
|
await notifyChatDeliveryWork(tx);
|
|
const [promoted] = await tx
|
|
.update(chatDeliveries)
|
|
.set({
|
|
state: "received",
|
|
normalizedEvent: sql`${chatDeliveries.normalizedEvent} - 'providerThreadPending'`,
|
|
nextAttemptAt: null,
|
|
redactedError: null,
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(
|
|
and(
|
|
eq(chatDeliveries.id, delivery.id),
|
|
eq(chatDeliveries.state, "retry"),
|
|
sql`${chatDeliveries.normalizedEvent}->>'providerThreadPending' = 'true'`,
|
|
),
|
|
)
|
|
.returning({ id: chatDeliveries.id });
|
|
await credentialLease.assertOwned(tx);
|
|
return Boolean(promoted);
|
|
});
|
|
},
|
|
);
|
|
} catch (error) {
|
|
const disposition = classifyChatPublicationError(
|
|
error,
|
|
delivery.attempts + 1,
|
|
);
|
|
const terminal =
|
|
disposition.kind === "resource_unavailable" ||
|
|
disposition.kind === "failed";
|
|
const retryAfterMs =
|
|
disposition.kind === "retry"
|
|
? disposition.retryAfterMs
|
|
: DISCORD_ROOT_THREAD_CONFIRMATION_DELAY_MS;
|
|
const now = new Date();
|
|
await db.transaction(async (tx) => {
|
|
if (disposition.kind === "resource_unavailable") {
|
|
const fence = lifecycleRuntimeFence(delivery);
|
|
const normalized = delivery.normalizedEvent as {
|
|
resource?: { providerResourceId?: unknown };
|
|
};
|
|
const providerResourceId = normalized.resource?.providerResourceId;
|
|
const current = fence
|
|
? await runtimeCallbackEndpoint(tx, endpoint.id, fence, [
|
|
"verifying",
|
|
"active",
|
|
"attention",
|
|
])
|
|
: null;
|
|
const resource =
|
|
current && typeof providerResourceId === "string"
|
|
? await tx
|
|
.select({ id: chatEndpointResources.id })
|
|
.from(chatEndpointResources)
|
|
.where(
|
|
and(
|
|
eq(chatEndpointResources.companyId, current.companyId),
|
|
eq(chatEndpointResources.endpointId, current.id),
|
|
eq(
|
|
chatEndpointResources.providerResourceId,
|
|
providerResourceId,
|
|
),
|
|
),
|
|
)
|
|
.for("update")
|
|
.then((rows) => rows[0] ?? null)
|
|
: null;
|
|
if (resource) {
|
|
await tx
|
|
.update(chatEndpointResources)
|
|
.set({ availability: "unavailable", updatedAt: now })
|
|
.where(eq(chatEndpointResources.id, resource.id));
|
|
await tx
|
|
.update(chatConversations)
|
|
.set({ state: "unavailable", updatedAt: now })
|
|
.where(
|
|
and(
|
|
eq(chatConversations.companyId, current!.companyId),
|
|
eq(chatConversations.endpointId, current!.id),
|
|
eq(chatConversations.resourceId, resource.id),
|
|
inArray(chatConversations.state, ["active", "waiting"]),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
await notifyChatDeliveryWork(tx);
|
|
await tx
|
|
.update(chatDeliveries)
|
|
.set({
|
|
state: terminal ? "filtered" : "retry",
|
|
attempts: delivery.attempts + 1,
|
|
nextAttemptAt: terminal
|
|
? null
|
|
: new Date(now.getTime() + retryAfterMs),
|
|
processedAt: terminal ? now : null,
|
|
redactedError: terminal
|
|
? "Discord thread creation was not confirmed"
|
|
: redactError(error),
|
|
updatedAt: now,
|
|
})
|
|
.where(
|
|
and(
|
|
eq(chatDeliveries.id, delivery.id),
|
|
eq(chatDeliveries.state, "retry"),
|
|
sql`${chatDeliveries.normalizedEvent}->>'providerThreadPending' = 'true'`,
|
|
),
|
|
);
|
|
});
|
|
return false;
|
|
}
|
|
}
|
|
return { admitDiscordRootMention, discordRootThreadPending, reconcileDiscordRootThread };
|
|
}
|