refactor(server): isolate chat conversation and control policies (#16276)

## Thinking Path

> - Paperclip helps people manage AI agents for work.
> - Chat channels turn external messages into shared task workflows.
> - The main service still contains some provider decisions.
> - These decisions belong with the existing channel modules.
> - This pull request separates conversation binding and Telegram
control policy.
> - Shared authorization, persistence, and runtime ownership keep their
current order.

## Linked Issues or Issue Description

Refs: #16235. Stack part 1/3. Base: `master`.

**What existing behavior does this improve?**

Provider ownership and readability in the chat channel service.

**Current behavior**

The entry point combines shared workflows with provider decisions.

**Proposed behavior**

Provider modules own their decisions. Shared modules own product state
and authorization.

**Reason and benefit**

Maintainers can find provider behavior in its channel folder.

**Breaking changes**

None intended. Public service methods and durable data shapes stay
compatible.

## What Changed

- Keep shared conversation lookup and completion in
`inbound/conversation-binding.ts`.
- Move Photon binding-completion policy to its messages module.
- Move Telegram command classification, forum behavior, and chronology
exemptions to its commands module.
- Keep the original conversation generation separate from the retained
task binding.
- Reduce `chat-channels.ts` from 3,649 to 3,598 lines.

## Verification

- The same 62 selected Telegram/Teams integration cases passed before
and after extraction.
- All 18 Photon channel integration cases passed before and after
extraction in separate test processes.
- The initial combined baseline command had a Photon database-startup
hook timeout. No Photon assertion ran in that invocation. The unchanged
Photon suite then passed in isolation with its existing timeouts.
- Server typecheck and the module-boundary check passed.
- A token comparison confirms the moved lookup, completion, and
generation logic, after normalizing the explicit policy callbacks.
- All current-head CI checks passed. Greptile reviewed this exact head
at 5/5 with no findings.
- Cumulative `pnpm -r typecheck` and `pnpm build` passed. All 1,321
channel integration cases passed. The normal UI, CLI, database, adapter,
plugin, and serialized API test selections ran.
- The first Linux `pnpm test:run` returned 1. Original-source runs
reproduced the local failures. Container-only repairs supplied missing
tools, process reaping, and sufficient Node heap. The existing
mocked-fetch CLI telemetry fixture passed with the image opt-out flag
overridden only for that fixture.
- One local venue limitation remains: adapter-utils T24 fails unchanged
on both original and final source in the Linux container. Its entire
56-case suite passes before and after on the host. All other failed or
aborted suites passed in repaired reruns. No test assertion or timeout
changed.
- These fixtures exercise the assembled service and persisted state.
They do not prove live third-party execution.

## Risks

- Changing query order or losing the original generation could bind a
message to the wrong task.
- The workflow retains the original lookup result and all existing query
and write conditions.
- No schema migration or API change is included.

## Model Used

OpenAI GPT-6, running in Codex with reasoning, tool use, and code
execution. The exact deployment identifier and context-window size are
not exposed in this session.

## 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
- [x] 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
- [x] All Paperclip CI gates are green
- [x] 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>
This commit is contained in:
DottaandPaperclip authored and GitHub committed 2026-10-14 10:21:48 -05:00
1 parent bd64eb27b0
commit f16c0ad5c8
4 files changed
+120 -75

No files matched your search

+24 -75
View File
@@ -1,3 +1,4 @@
import { resolveInboundConversationBinding } from "./chat-channels/inbound/conversation-binding.js";
import { createGitHubMaintenance } from "./chat-channels/github/maintenance.js";
import { createSpekoChannelService, authorizeSpekoPublicVoiceTask, replaceSpekoChannelRuntime } from "./chat-channels/speko/runtime.js";
import { prepareDiscordRuntimeCommand } from "./chat-channels/discord/runtime-commands.js";
@@ -6,7 +7,7 @@ import { isSupportedTelegramWebhookBaseUrl } from "./chat-channels/telegram/setu
import { initializeGitHubWebhookRuntime } from "./chat-channels/github/webhook-runtime.js";
import { githubInboundTaskTitle, githubInboundCommentText, githubAssignedAgentRequiresLowTrust, retainGitHubDeliveryAuthority, retainGitHubReviewReceipt } from "./chat-channels/github/task-preparation.js";
import { applyChatTaskReviewContainment } from "./chat-channels/inbound/task-trust.js";
import { photonControlTargetsConversation, photonReplyMetadata, recordPhotonInboundActivity } from "./chat-channels/imessage-photon/messages.js";
import { photonConversationBindingIsComplete, photonControlTargetsConversation, photonReplyMetadata, recordPhotonInboundActivity } from "./chat-channels/imessage-photon/messages.js";
import { deferTeamsSetupReply } from "./chat-channels/microsoft-teams/setup-replies.js";
import { slackOrphanFollowUp, stripSlackTitleMentions } from "./chat-channels/slack/messages.js";
import { createChatPublicationResolution } from "./chat-channels/publications/resolution.js";
@@ -61,7 +62,7 @@ import { createSlackTaskAuthorityRegistration } from "./chat-channels/slack/task
import { createGitHubPublicationPolicy } from "./chat-channels/github/publication-policy.js";
import { createTelegramAttachmentFailure } from "./chat-channels/telegram/attachment-failure.js";
import { createSlackCommands } from "./chat-channels/slack/commands.js";
import { createTelegramCommands, telegramGuidanceCommand, telegramGuidanceResponse, telegramForumControlResponse } from "./chat-channels/telegram/commands.js";
import { createTelegramCommands, telegramInboundControls, telegramGuidanceResponse, telegramForumControlResponse } from "./chat-channels/telegram/commands.js";
import { createTeamsFileDelivery } from "./chat-channels/microsoft-teams/file-delivery.js";
import { createGitHubSetupSecret } from "./chat-channels/github/setup-secret.js";
import { createSlackInventory } from "./chat-channels/slack/inventory.js";
@@ -1680,72 +1681,25 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) {
})
.where(eq(chatDeliveries.id, activeDelivery.id));
const latestConversation =
endpoint.provider === "microsoft-teams"
? await conversationForThread(endpoint.id, thread.id)
: await db
.select()
.from(chatConversations)
.where(
and(
eq(chatConversations.endpointId, endpoint.id),
eq(
chatConversations.externalConversationId,
thread.channelId,
),
eq(chatConversations.externalThreadId, thread.id),
),
)
.orderBy(desc(chatConversations.sessionGeneration))
.then((rows) => rows[0] ?? null);
const isLinear = endpoint.provider !== "speko" && surfaceKind !== "native_thread";
let existingConversation: ConversationRow | null = latestConversation;
let existingIssue: typeof issues.$inferSelect | null =
existingConversation
? await db
.select()
.from(issues)
.where(
and(
eq(issues.companyId, endpoint.companyId),
eq(issues.id, existingConversation.issueId),
),
)
.then((rows) => rows[0] ?? null)
: null;
if (
isLinear &&
existingConversation &&
(endpoint.provider === "imessage-photon"
// A reply finishes an iMessage turn, not the conversation. Only a
// delivered /new or /close releases this chat's task binding. Use
// the durable control receipt so pre-fix completed rows also resume.
? await hasCommittedTaskControlCompletion(existingConversation.id)
: existingConversation.state === "completed" ||
existingIssue?.status === "done" ||
existingIssue?.status === "cancelled")
) {
if (existingConversation.state !== "completed") {
await db
.update(chatConversations)
.set({ state: "completed", updatedAt: new Date() })
.where(eq(chatConversations.id, existingConversation.id));
}
existingConversation = null;
existingIssue = null;
}
// Telegram exposes its small command vocabulary in forum topics too.
// A forum topic is a native provider thread and therefore stays bound to
// one immutable Paperclip task, but the command must still be consumed
// as control-plane input instead of becoming a task comment/wakeup.
const controlCommand =
isLinear || endpoint.provider === "telegram"
? linearControlCommand(message.text)
: null;
const guidanceCommand =
endpoint.provider === "telegram"
? telegramGuidanceCommand(message.text)
: null;
let { conversation: existingConversation, issue: existingIssue, latestConversation, isLinear } =
await resolveInboundConversationBinding(db, {
endpoint, thread,
isLinear: () => endpoint.provider !== "speko" && surfaceKind !== "native_thread",
lookupConversation: endpoint.provider === "microsoft-teams"
? () => conversationForThread(endpoint.id, thread.id)
: undefined,
conversationIsComplete: endpoint.provider === "imessage-photon"
? (conversationId) => photonConversationBindingIsComplete(conversationId, hasCommittedTaskControlCompletion)
: undefined,
});
const telegramControls = endpoint.provider === "telegram"
? telegramInboundControls(message.text, surfaceKind)
: null;
const controlCommand = telegramControls
? telegramControls.controlCommand
: isLinear ? linearControlCommand(message.text) : null;
const guidanceCommand = telegramControls?.guidanceCommand ?? null;
const bypassesControlChronology = telegramControls?.bypassesControlChronology ?? controlCommand === "status";
const endpointAllowed =
endpoint.status === "verifying" || endpoint.status === "active";
const destinationAllowed = thread.isDM
@@ -1853,11 +1807,7 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) {
const filterPreControlSource = async (database: DbOrTransaction) => {
if (
controlCommand === "status" ||
(controlCommand === "new" &&
endpoint.provider === "telegram" &&
surfaceKind === "native_thread") ||
guidanceCommand ||
bypassesControlChronology ||
(await readChatControlChronology(
database,
endpoint,
@@ -2010,8 +1960,7 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) {
}
if (controlCommand) {
const isTelegramForumTopic =
endpoint.provider === "telegram" && surfaceKind === "native_thread";
const isTelegramForumTopic = telegramControls?.isForumTopic === true;
const taskLabel = existingIssue
? `${existingIssue.identifier}: ${existingIssue.title}`
: "No task is active in this conversation.";
@@ -55,3 +55,13 @@ export async function recordPhotonInboundActivity(
inboundActivityPublications,
);
}
export async function photonConversationBindingIsComplete(
conversationId: string,
hasCommittedTaskControlCompletion: (conversationId: string) => Promise<boolean>,
): Promise<boolean> {
// A reply finishes an iMessage turn, not the conversation. Only a
// delivered /new or /close releases this chat's task binding. Use
// the durable control receipt so pre-fix completed rows also resume.
return await hasCommittedTaskControlCompletion(conversationId);
}
@@ -0,0 +1,72 @@
import { chatConversations, issues, type Db } from "@paperclipai/db";
import { and, desc, eq } from "drizzle-orm";
import type { EndpointRow } from "../runtime-types.js";
type ConversationRow = typeof chatConversations.$inferSelect;
type ConversationBindingInput = {
endpoint: Pick<EndpointRow, "id" | "companyId">;
thread: { id: string; channelId: string };
isLinear: () => boolean;
lookupConversation?: () => Promise<ConversationRow | null>;
conversationIsComplete?: (conversationId: string) => Promise<boolean>;
};
export async function resolveInboundConversationBinding(
db: Db,
input: ConversationBindingInput,
) {
const { endpoint, thread, lookupConversation, conversationIsComplete } = input;
const latestConversation =
lookupConversation
? await lookupConversation()
: await db
.select()
.from(chatConversations)
.where(
and(
eq(chatConversations.endpointId, endpoint.id),
eq(
chatConversations.externalConversationId,
thread.channelId,
),
eq(chatConversations.externalThreadId, thread.id),
),
)
.orderBy(desc(chatConversations.sessionGeneration))
.then((rows) => rows[0] ?? null);
const isLinear = input.isLinear();
let existingConversation: ConversationRow | null = latestConversation;
let existingIssue: typeof issues.$inferSelect | null =
existingConversation
? await db
.select()
.from(issues)
.where(
and(
eq(issues.companyId, endpoint.companyId),
eq(issues.id, existingConversation.issueId),
),
)
.then((rows) => rows[0] ?? null)
: null;
if (
isLinear &&
existingConversation &&
(conversationIsComplete
? await conversationIsComplete(existingConversation.id)
: existingConversation.state === "completed" ||
existingIssue?.status === "done" ||
existingIssue?.status === "cancelled")
) {
if (existingConversation.state !== "completed") {
await db
.update(chatConversations)
.set({ state: "completed", updatedAt: new Date() })
.where(eq(chatConversations.id, existingConversation.id));
}
existingConversation = null;
existingIssue = null;
}
return { conversation: existingConversation, issue: existingIssue, latestConversation, isLinear };
}
@@ -1,3 +1,4 @@
import { linearControlCommand, type chatSurfaceKind } from "../inbound/surfaces.js";
import type { EndpointRow, RuntimeContext } from "../runtime-types.js";
import {
@@ -128,3 +129,16 @@ export function telegramForumControlResponse(command: "new" | "close", taskLabel
? "This chat conversation is closed. A later message here will continue the same Paperclip task."
: "No task is active. Send a message to start one.";
}
export function telegramInboundControls(text: string, surfaceKind: ReturnType<typeof chatSurfaceKind>) {
// Telegram exposes its small command vocabulary in forum topics too.
// A forum topic is a native provider thread and therefore stays bound to
// one immutable Paperclip task, but the command must still be consumed
// as control-plane input instead of becoming a task comment/wakeup.
const controlCommand = linearControlCommand(text);
const guidanceCommand = telegramGuidanceCommand(text);
const isForumTopic = surfaceKind === "native_thread";
const bypassesControlChronology = controlCommand === "status" ||
(controlCommand === "new" && isForumTopic) || guidanceCommand !== null;
return { controlCommand, guidanceCommand, isForumTopic, bypassesControlChronology };
}