Files
PaperClipAI/server/src/services/chat-channels/discord/root-threads.ts
T
DottaandPaperclip 64bd6e9e07 refactor(chat): extract delivery, reaction, and setup boundaries (#16024)
## 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>
2026-10-12 22:46:25 +00:00

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 };
}