Files
PaperClipAI/server/src/services/chat-sdk-runtime.ts
T
2e35730637 feat(chat): receive sealed Slack events through the current SDK owner (#19019)
## Thinking Path

> - Paperclip manages AI agents and their work.
> - Slack connectors turn authenticated messages into company work.
> - Direct callbacks need an awake tenant.
> - Cloud can retain authenticated asynchronous events until the tenant
wakes.
> - Replay must preserve the original callback proof and current
endpoint authority.
> - Configuration changes must retain registration responsibility
through failures.
> - This PR enrolls verified Slack identities and drains sealed events
through the normal SDK owner.

## Linked Issues or Issue Description

**Subsystem affected**

Server chat connectors and signed Cloud connector delivery.

**Problem or motivation**

Direct Slack Events API delivery depends on an awake tenant. Broker
outages, lost responses and configuration changes must preserve delivery
and registration responsibility. Enrollment failures must also leave
other connector events able to drain.

**Proposed solution**

Stage nonsecret enrollment or retirement intent inside the local
configuration transaction. Let the existing connection-event owner
perform broker requests and retries. Replay sealed events through the
current Slack SDK with their original authentication evidence.

**Alternatives considered**

An early acknowledgment for every callback changes command and modal
semantics. A second delivery ledger duplicates existing ownership.
Reusing a new stack identity to retire old registrations crosses an
authorization boundary.

A companion Cloud broker change is required. Related setup fixes: Refs
#18837, Refs #18878. Kickoff searches found no competing async
enrollment consumer. The ROADMAP's connector setup and access controls
stay in place.

## What Changed

- Persist enrollment and credential-free retirement intent before local
configuration commits. Broker failure does not make a completed pause or
removal look unsuccessful.
- Bind company, endpoint, connection, public ID, generation and
authorized actor. Preserve lost-response responsibility and recover only
the supported same-instance configuration outage.
- Permit an explicit move to a new authorized stack only after confirmed
old retirement. Keep the old identity until the new broker receipt
matches the current intent and generation.
- Show permanent signing-credential failures as attention. Preserve
transient retries and reject uncertain inbound authentication before SDK
admission.
- Drain events and acknowledgments before enrollment reconciliation.
Persist a bounded retry on the exact busy intent so other due endpoints
can enroll or retire. Preserve current custody under concurrent changes.
- Stage retirement in the Apps removal transaction too. Archive the
endpoint and connection, retain the original subject and instance, then
revoke credentials. Retirement does not need the deleted Slack
credential.
- Acknowledge a malformed sealed Slack receipt as invalid once. Keep
actual broker outages on the retry path.
- Run the real configuration, SDK ingress, generation fences and public
Apps removal paths in the default SQL suite. Keep Vitest transport
helpers outside production services.
- Preserve original callback bytes, signature, time and public URL
evidence. Use the existing transactional chat-delivery ledger for
redelivery.
- Provide a checked-in prewire guard and launcher using the current Node
executable. Add broker, worker, SDK, SQL and controlled-process
acceptance.
- Stabilize AI account summaries by ID and grant ID. This fixes a CI
migration-repeat equality failure without changing SQL, authorization or
reconnect behavior.
- Open the four configured database connections before the enrollment
test freezes its clock. Keep all 35 original assertions and all
timeouts.
- Measure status-card dispatch entry before its mock does database work.
Keep the original strict 500 ms bound and check any still-armed earlier
deadline. Bound that fixture's pool to four connections.

## Verification

- Candidate: `54fa701d41b4ced62f4ff338d39b946015670ae3`. Normal merges
preserve master `47027e7` and all owned changes. A read-only merge
assessment against fetched master `cb5d7e44` is conflict-free. The only
overlapping file has separate AI test additions. The branch has not
merged that later master; fresh remote checks must validate the current
PR test tree.
- At this head, normal server production typechecking and declaration
emission passed in 35.24s. The affected actual CI test shard passed in
6.27s. Other unchanged CI layers, 92 affected pure controls and 12 guard
controls retain their source-matched results.
- All **15 default real-PostgreSQL cases passed in 34.66s**, with no
skips or filtering. They cover normal configure and SDK ingress,
rollback, stale generations, malformed receipts, busy-lease fairness,
concurrent intent custody, unknown errors, and public Apps removal.
Removal tests check actual credential revocation, retained retirement
through outage and repeat removal, one ordinary retry and confirmed
retirement, transaction rollback and foreign-company refusal.
- The public removal counterfactual failed before the fix: archival and
credential revocation passed, but the inactive intent was absent. The
same test source passed after the staging call. Separate counterfactuals
also reproduced busy-endpoint starvation and malformed-receipt retries.
Original failures remain retained.
- The paired broker/SDK/process journey passed in 31.98s at ancestor
`0324c011`. It preserves callback proof, lost-ACK redelivery, exactly
one comment, one delivery and one successful controlled process. Its
test, producer, SDK, guard and runtime bytes are unchanged. The new
public Apps archive branch is not called by that journey; its evidence
carries on that checked basis.
- The new SQL cases need no companion Cloud checkout. Normal CI runs
them with the repository fixture. For an existing dedicated PostgreSQL
fixture, set `PAPERCLIP_SLACK_OWNER_TEST_DATABASE_URL` and
`PAPERCLIP_TEST_POSTGRES_ADMIN_URL`, then run `node
server/scripts/test-slack-cloud-owner.mjs` with Node24. The optional
paired launcher still requires the normally built companion Cloud
checkout.
- Every pool was bounded to four or fewer connections. The final 15-case
run sampled three runtime clients plus one observer. The independent
final audit found zero clients and all five recorded processes absent.
Earlier closing-socket observations are kept separate from final
cleanup.
- Earlier unrelated CI failures are documented: [enrollment
timeout](https://github.com/paperclipai/paperclip/actions/runs/41944373937/job/126973483754)
and [status-card observation
bound](https://github.com/paperclipai/paperclip/actions/runs/41944373937/job/126973483843).
The enrollment test retains all 35 assertions and its original timeouts.
A controlled connection probe established the frozen-clock mechanism;
the original remote access history remains unknown. The status-card
fixture measures dispatch entry with the original strict 500ms bound. A
real delayed-dispatch control fails that bound.
- Full local workspace typecheck, build and test inventory was not run
because its embedded-cluster and wider resource paths exceed this local
lease. Exact-head remote CI now passes: 54 successful checks, three
skips and Ponytail's neutral check, with aggregate verification green
and the legacy security context independently confirmed successful. Both
complete bot reviews are 5/5, with all 13 paginated review threads
resolved. Seven execution jobs originally reported runner shutdown; one
diagnostic rerun recovered them and the dependent aggregate check.
Original logs remain retained, and the underlying shutdown cause is
unknown. This is recovery evidence, not a product-fix claim.
- Provider responses are controlled. This proves local SQL, broker, SDK
and process behavior. It does not prove live Slack, agent tools,
synchronous callbacks or deployed suspension.

## Risks

The companion broker is required. Outage or conflicting authority can
leave explicit pending or attention responsibility. A move between
stacks requires confirmation from the old authorized owner first.
Synchronous commands, actions, modal validation, options and challenges
retain their deadlines and endpoint blocker. This slice grants no full
Slack endpoint sleep exemption. No deployed suspension or savings claim
is made.

## Model Used

OpenAI `gpt-6.1-sol` workers through Codex, with reasoning, repository
tools and code execution. The served backend revision and context limit
are not exposed. A GPT-6-family coordinator performed technical review
and integration.

## 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>
Co-authored-by: Codex <noreply@openai.com>
2026-11-06 15:22:51 -06:00

3323 lines
114 KiB
TypeScript

import { isPassiveSlackAction, normalizeSlackSelectionAction, slackSelectionValues } from "./chat-channels/slack/cards.js";
import { PhotonChatAdapter, parsePhotonThreadId } from "./photon/adapter.js";
import { PhotonLineAuthentication } from "./photon/cloud.js";
import { PhotonState } from "./photon/state.js";
import { PhotonReceiver } from "./photon/receiver.js";
import { photonAttachmentLocator, photonAttachmentLocatorSchema, downloadPhotonAttachment, type PhotonAttachmentLocator } from "./photon/attachments.js";
import type { LiveEvent as PhotonEvent } from "@photon-ai/advanced-imessage";
import {
createGitHubAdapter,
type GitHubAdapter,
type GitHubAdapterConfig,
} from "@chat-adapter/github";
import {
createDiscordAdapter,
type DiscordAdapter,
type DiscordAdapterConfig,
} from "@chat-adapter/discord";
import type { SlackAdapterConfig } from "@chat-adapter/slack";
import { PaperclipSlackAdapter } from "./chat-channels/slack/adapter.js";
import { verifySlackSignature } from "@chat-adapter/slack/webhook";
import { slackCloudAcceptedAt } from "./chat-channels/slack/cloud-event.js";
import {
createTeamsAdapter,
type TeamsAdapter,
type TeamsAdapterConfig,
} from "@chat-adapter/teams";
import {
createTelegramAdapter,
type TelegramAdapter,
type TelegramAdapterConfig,
type TelegramRawMessage,
} from "@chat-adapter/telegram";
import { AsyncLocalStorage } from "node:async_hooks";
import {
Chat,
type ActionEvent,
type Adapter,
type Attachment,
type Author,
type Channel,
type ConcurrencyConfig,
type ConcurrencyStrategy,
type Logger,
type Message,
type MessageContext,
type MessageDeletedEvent,
type ModalCloseEvent,
type ModalResponse,
type ModalSubmitEvent,
type OptionsLoadEvent,
type OptionsLoadResult,
type ReactionEvent,
type SlashCommandEvent,
type Thread,
type UserInfo,
type WebhookOptions,
} from "chat";
import type { StateAdapter } from "chat";
import { MAX_ATTACHMENT_BYTES } from "../attachment-types.js";
import {
deriveTeamsInlineImageLocator,
parseTeamsInlineImageLocator,
teamsInlineImageDownloadUrl,
type TeamsInlineImageLocator,
type TeamsInlineImageScope,
} from "./chat-teams-inline-image-intake.js";
import { normalizeTelegramVideoNoteAttachments } from "./chat-telegram-video-note.js";
import {
hasTelegramMediaProvenance,
normalizeTelegramMediaAttachments,
retainTelegramMediaProvenance,
telegramMediaLocator,
validateTelegramMediaLocator,
type TelegramMediaLocator,
type TelegramMediaScope,
} from "./chat-telegram-media-intake.js";
import { normalizeTelegramRichMessage } from "./chat-telegram-rich-intake.js";
import {
captureTelegramGenerationStopped,
telegramPrivateDraftDestination,
type TelegramDraftControl,
type TelegramDraftStopped,
type TelegramGenerationStoppedProof,
} from "./chat-telegram-draft-stop.js";
import {
applySlackReceiptReaction,
type SlackReceiptMutation,
} from "./chat-slack-receipts.js";
import {
applyGitHubReceiptReaction,
type GitHubReceiptMutation,
} from "./chat-github-receipt-reactions.js";
import {
captureTelegramCallbackProvenance,
hasTelegramEphemeralInput,
sendTelegramCallbackNotice,
type TelegramCallbackProvenance,
type TelegramCallbackReceipt,
} from "./chat-telegram-ephemeral.js";
import {
installTeamsFileConsentHook,
parseTeamsFileConsentCard,
parseTeamsUploadedFileCard,
type buildTeamsFileConsentCard,
type buildTeamsUploadedFileCard,
type TeamsConsentApp,
type TeamsFileConsentEvent,
} from "./chat-teams-file-consent.js";
import {
githubAttachmentLocator,
githubAttachmentCommentFetch,
githubAttachmentDiagnosticCode,
GitHubAttachmentUnavailableError,
isGitHubAttachmentCommentRequest,
githubPublicAttachmentsFromMessage,
rehydrateGitHubPublicAttachment,
validateGitHubAttachmentLocator,
type GitHubPublicAttachmentLocator,
type GitHubAttachmentCommentRequest,
} from "./chat-github-attachments.js";
import {
createPaperclipChatSdkState,
type ChatSdkStatePersistence,
} from "./chat-sdk-state.js";
export const CHAT_SDK_VERSION = "4.39.0";
export const CHAT_SDK_SOURCE_REVISION =
"51322dde8f4aafd8a7fc7a20cbfd7ae45cafaa5c";
const SLACK_WEB_API_TIMEOUT_MS = 45_000;
const GITHUB_API_TIMEOUT_MS = 25_000;
// discord.js owns resume and reconnect while a Client remains alive. Keep the
// client up for a day instead of deliberately creating a disconnect window
// every few minutes; the outer loop still recovers if the adapter exits.
const DISCORD_GATEWAY_SESSION_MS = 24 * 60 * 60_000;
const DISCORD_GATEWAY_RESTART_MAX_DELAY_MS = 60_000;
const DISCORD_GATEWAY_HEALTHY_SESSION_MS = 60_000;
/** Public Paperclip provider ids. The Teams SDK name remains an internal detail. */
export type ChatSdkProvider =
"slack" | "github" | "discord" | "microsoft-teams" | "telegram" | "imessage-photon";
type ChatSdkAdapterKey = "slack" | "github" | "discord" | "teams" | "telegram" | "imessage-photon";
interface ProviderConfigBase {
/** Agent-derived native bot display/mention name. */
userName: string;
}
export interface ResolvedSlackChatConfig extends ProviderConfigBase {
provider: "slack";
credentials: {
apiUrl?: string;
botToken: string;
botUserId?: string;
signingSecret: string;
};
}
export type ResolvedGitHubCredentials =
| {
apiUrl?: string;
botUserId?: number;
token: string;
webhookSecret: string;
}
| {
apiUrl?: string;
appId: string;
botUserId?: number;
installationId?: number;
privateKey: string;
webhookSecret: string;
};
export interface ResolvedGitHubChatConfig extends ProviderConfigBase {
provider: "github";
credentials: ResolvedGitHubCredentials;
}
export interface ResolvedDiscordChatConfig extends ProviderConfigBase {
provider: "discord";
credentials: {
apiUrl?: string;
applicationId: string;
botToken: string;
guildId: string;
};
}
export interface ResolvedMicrosoftTeamsChatConfig extends ProviderConfigBase {
provider: "microsoft-teams";
credentials: {
apiUrl?: string;
appId: string;
appPassword: string;
appTenantId?: string;
appType?: "MultiTenant" | "SingleTenant";
};
}
export interface ResolvedTelegramChatConfig extends ProviderConfigBase {
provider: "telegram";
maxDownloadBytes?: number;
credentials: {
apiUrl?: string;
botToken: string;
secretToken: string;
};
}
export interface ResolvedPhotonChatConfig extends ProviderConfigBase {
provider: "imessage-photon";
intakeAfter: number;
credentials: { allocation?: "dedicated" | "shared"; projectId: string; projectSecret: string; lineId: string; phoneNumber: string };
}
export type ResolvedChatSdkProviderConfig =
| ResolvedPhotonChatConfig
| ResolvedSlackChatConfig
| ResolvedGitHubChatConfig
| ResolvedDiscordChatConfig
| ResolvedMicrosoftTeamsChatConfig
| ResolvedTelegramChatConfig;
export type ChatSdkMessageTrigger =
"direct_message" | "mention" | "subscribed_message" | "unaddressed_message";
type DurableAttachmentType = Attachment["type"];
interface DurableAttachmentMetadata {
height?: number;
mimeType?: string;
name?: string;
size?: number;
type: DurableAttachmentType;
width?: number;
}
type ChatSdkAttachmentLocator =
| PhotonAttachmentLocator
| GitHubPublicAttachmentLocator
| TeamsInlineImageLocator
| TelegramMediaLocator
| {
enterpriseId?: string;
isEnterpriseInstall?: true;
kind: "slack_private_url";
teamId?: string;
url: string;
}
| {
connectorOrigin: string;
kind: "teams_bot_url";
url: string;
}
| {
kind: "discord_cdn_url";
url: string;
}
| {
kind: "teams_anonymous_url";
url: string;
}
| {
fileId: string;
fileUniqueId?: string;
kind: "telegram_file_id";
};
/**
* JSON-safe attachment data that may be stored with a durable delivery.
*
* This intentionally excludes `fetchData`, binary data, authorization
* headers, bot/app tokens, signing secrets, and arbitrary adapter metadata.
* Provider locators are a closed allowlist sufficient for the pinned Chat SDK
* adapters to rebuild their authenticated download closure after restart.
*/
export interface ChatSdkAttachmentRecoveryDescriptor {
attachment: DurableAttachmentMetadata;
locator: ChatSdkAttachmentLocator;
provider: ChatSdkProvider;
version: 1;
}
export interface ChatSdkAttachmentSource {
threadId: string;
messageId: string;
runtimeGeneration?: number;
credentialFingerprint?: string;
principalExternalId?: string;
message?: Message;
isDirectMessage?: boolean;
}
const ATTACHMENT_TYPES = new Set<DurableAttachmentType>([
"audio",
"file",
"image",
"video",
]);
function boundedAttachmentText(
value: unknown,
maximum: number,
): string | undefined {
if (typeof value !== "string") return undefined;
const sanitized = value.replace(/[\u0000-\u001f\u007f]/g, "").trim();
return sanitized ? sanitized.slice(0, maximum) : undefined;
}
function boundedAttachmentNumber(value: unknown): number | undefined {
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0
? value
: undefined;
}
function durableAttachmentMetadata(
attachment: Attachment,
): DurableAttachmentMetadata | null {
if (!ATTACHMENT_TYPES.has(attachment.type)) return null;
const name = boundedAttachmentText(attachment.name, 512);
const mimeType = boundedAttachmentText(attachment.mimeType, 255);
const size = boundedAttachmentNumber(attachment.size);
const width = boundedAttachmentNumber(attachment.width);
const height = boundedAttachmentNumber(attachment.height);
return {
type: attachment.type,
...(name ? { name } : {}),
...(mimeType ? { mimeType } : {}),
...(size !== undefined ? { size } : {}),
...(width !== undefined ? { width } : {}),
...(height !== undefined ? { height } : {}),
};
}
function sanitizedRecoveryUrl(
value: unknown,
options?: { allowQuery?: boolean; requiredOrigin?: string },
): string | null {
if (typeof value !== "string" || value.length > 8192) return null;
try {
const parsed = new URL(value);
if (
parsed.protocol !== "https:" ||
parsed.username ||
parsed.password ||
(options?.requiredOrigin && parsed.origin !== options.requiredOrigin)
) {
return null;
}
if (!options?.allowQuery && parsed.search) return null;
parsed.hash = "";
return parsed.toString();
} catch {
return null;
}
}
function sanitizedRecoveryOrigin(value: unknown): string | null {
const url = sanitizedRecoveryUrl(value);
if (!url) return null;
const parsed = new URL(url);
if (parsed.pathname !== "/" || parsed.search) return null;
return parsed.origin;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
function microsoftTeamsTenantIds(raw: unknown): string[] {
if (!isRecord(raw)) return [];
const conversation = isRecord(raw.conversation) ? raw.conversation : null;
const channelData = isRecord(raw.channelData) ? raw.channelData : null;
const tenant =
channelData && isRecord(channelData.tenant) ? channelData.tenant : null;
return (
[conversation?.tenantId, tenant?.id]
.filter(
(value): value is string =>
typeof value === "string" && value.trim().length > 0,
)
// Microsoft Entra tenant IDs are UUIDs (or, for some supported identity
// configurations, DNS-style tenant names). Both forms are
// case-insensitive. A user can paste a mixed-case value that the token
// endpoint accepts while Bot Framework emits the canonical lowercase
// form, so normalize before enforcing the endpoint boundary.
.map((value) => value.trim().toLowerCase())
);
}
/** Chat SDK-normalized inbound message plus Paperclip endpoint identity. */
export interface ChatSdkMessageCallbackEvent {
context?: MessageContext;
endpointId: string;
message: Message;
/** Telegram's monotonic webhook update id, when this callback came from a webhook. */
providerUpdateId?: number;
provider: ChatSdkProvider;
thread: Thread;
trigger: ChatSdkMessageTrigger;
}
export interface ChatSdkMessageUpdatedCallbackEvent {
endpointId: string;
message: Message;
previousMessage?: Message;
provider: ChatSdkProvider;
thread: Thread;
}
export interface ChatSdkCallbackEvent<T> {
endpointId: string;
event: T;
/** Provider-normalized selected opaque values from an explicit message submit. */
selectionValues?: string[];
provider: ChatSdkProvider;
/** Runtime-assigned ingress context; never read from provider payloads. */
transport?: "discord_gateway";
/** Opaque runtime-owned proof from the authenticated Telegram callback. */
telegramCallback?: TelegramCallbackProvenance;
}
/** Private command completion only; task publications use the ordinary FIFO. */
export type DiscordNativeCommandResponse =
| { kind: "accepted"; content: string }
| { kind: "denied" };
export interface DiscordRootMentionAdmissionEvent {
channelId: string;
endpointId: string;
guildId: string;
message?: Message;
messageId: string;
threadId: string;
userId: string;
}
export type DiscordGatewayHealthEvent =
| { type: "connecting" }
| { type: "ready"; botUserId?: string }
| {
type: "failure";
fatal: boolean;
error: {
name: string;
code?: number | string;
status?: number;
retryAfter?: number;
};
}
| { type: "disconnected"; fatal: boolean; code?: number }
| { type: "guild_removed"; guildId: string }
| { type: "guild_available"; guildId: string }
| { type: "guild_unavailable"; guildId: string }
| {
type: "channel_removed";
channelId: string;
guildId?: string;
label?: string;
};
export interface DiscordGatewayCallbackEvent extends ChatSdkCallbackEvent<DiscordGatewayHealthEvent> {
sequence: number;
}
/**
* Provider-neutral callbacks consumed by the Paperclip control-plane service.
* All provider events have already passed the installed adapter's verifier and
* normalization. Raw provider payloads remain available only through the
* Chat SDK event escape hatches; callers must never publish them directly.
*/
export interface ChatSdkRuntimeCallbacks {
onPhotonEvent?(event: PhotonEvent): Promise<void>;
onPhotonAssertOwned?(activeThreadId?: string): Promise<void>;
onPhotonCheckpoint?(sequence: number): Promise<void>;
onPhotonFailure?(error: unknown): Promise<void>;
onMessage(event: ChatSdkMessageCallbackEvent): Promise<void> | void;
onTelegramGenerationStopped?(
event: ChatSdkCallbackEvent<TelegramGenerationStoppedProof>,
): Promise<void> | void;
onDiscordRootMentionAdmission?(
event: DiscordRootMentionAdmissionEvent,
): Promise<boolean> | boolean;
onDiscordGatewayEvent?(
event: DiscordGatewayCallbackEvent,
): Promise<void> | void;
onAction?(event: ChatSdkCallbackEvent<ActionEvent>): Promise<void> | void;
/** Optional personal-chat file lane; "recorded" is not file delivery. */
onTeamsFileConsent?(
event: ChatSdkCallbackEvent<TeamsFileConsentEvent>,
):
| Promise<"recorded" | "ignored" | "denied">
| "recorded"
| "ignored"
| "denied";
onMessageDeleted?(
event: ChatSdkCallbackEvent<MessageDeletedEvent>,
): Promise<void> | void;
onMessageUpdated?(
event: ChatSdkMessageUpdatedCallbackEvent,
): Promise<void> | void;
onModalClose?(
event: ChatSdkCallbackEvent<ModalCloseEvent>,
): Promise<void> | void;
onModalSubmit?(
event: ChatSdkCallbackEvent<ModalSubmitEvent>,
):
| Promise<ModalResponse | undefined | void>
| ModalResponse
| undefined
| void;
onOptionsLoad?(
event: ChatSdkCallbackEvent<OptionsLoadEvent>,
): Promise<OptionsLoadResult | undefined> | OptionsLoadResult | undefined;
onReaction?(event: ChatSdkCallbackEvent<ReactionEvent>): Promise<void> | void;
onSlashCommand?(
event: ChatSdkCallbackEvent<SlashCommandEvent>,
):
| Promise<DiscordNativeCommandResponse | void>
| DiscordNativeCommandResponse
| void;
}
export interface CreateChatSdkEndpointRuntimeOptions {
callbacks: ChatSdkRuntimeCallbacks;
companyId: string;
concurrency?: ConcurrencyConfig | ConcurrencyStrategy;
/** Start Discord's long-lived Gateway listener for the elected owner only. */
enableDiscordGateway?: boolean;
endpointId: string;
logger?: Logger | "debug" | "error" | "info" | "silent" | "warn";
maxStateValueBytes?: number;
persistence: ChatSdkStatePersistence;
providerConfig: ResolvedChatSdkProviderConfig;
webhookIngressTimeoutMs?: number;
}
function adapterKey(provider: ChatSdkProvider): ChatSdkAdapterKey {
return provider === "microsoft-teams" ? "teams" : provider;
}
function adapterLogger(
logger: CreateChatSdkEndpointRuntimeOptions["logger"],
): Logger | undefined {
return logger && typeof logger !== "string" ? logger : undefined;
}
const TEAMS_THREAD_SCOPED_METHODS = [
"postMessage",
"postEphemeral",
"editMessage",
"deleteMessage",
"addReaction",
"removeReaction",
"startTyping",
"stream",
"postChannelMessage",
] as const;
const OFFICIAL_TEAMS_CONNECTOR_HOST_SUFFIXES = [
// Signed Teams activity can carry regional Microsoft-owned Bot Framework
// service URLs, so the egress trust boundary recognizes these exact domain
// families. Host acceptance is defensive routing validation, not a claim
// that Paperclip's commercial-cloud-only credential flow supports every
// Microsoft cloud represented by a first-party hostname.
"botframework.com",
"smba.trafficmanager.net",
"teams.microsoft.com",
"teams.microsoft.us",
] as const;
function isOfficialTeamsConnectorHost(hostname: string): boolean {
return OFFICIAL_TEAMS_CONNECTOR_HOST_SUFFIXES.some(
(suffix) => hostname === suffix || hostname.endsWith(`.${suffix}`),
);
}
function isCanonicalTeamsConnectorPath(pathname: string): boolean {
// A Bot Connector service URL is a base URL, not an arbitrary connector API
// route. Microsoft-owned URLs use either no path or one bounded
// region/service segment such as /amer, /amer-client-ss.msg, or /teams.
return (
pathname === "/" || /^\/[a-z0-9][a-z0-9._-]{0,127}\/?$/i.test(pathname)
);
}
export class TeamsServiceUrlValidationError extends Error {
readonly code = "CHAT_PROVIDER_PRETRANSPORT_REJECTED";
readonly provider = "microsoft-teams";
constructor(message: string) {
super(message);
this.name = "TeamsServiceUrlValidationError";
}
}
export class TeamsAdapterCompatibilityError extends Error {
readonly code = "CHAT_ADAPTER_COMPATIBILITY_ERROR";
constructor(detail: string) {
super(`The pinned Microsoft Teams adapter is incompatible: ${detail}`);
this.name = "TeamsAdapterCompatibilityError";
}
}
export class DiscordAdapterCompatibilityError extends Error {
readonly code = "CHAT_ADAPTER_COMPATIBILITY_ERROR";
constructor(detail: string) {
super(`The pinned Discord adapter is incompatible: ${detail}`);
this.name = "DiscordAdapterCompatibilityError";
}
}
export class SlackAdapterCompatibilityError extends Error {
readonly code = "CHAT_ADAPTER_COMPATIBILITY_ERROR";
constructor(detail: string) {
super(`The pinned Slack adapter is incompatible: ${detail}`);
this.name = "SlackAdapterCompatibilityError";
}
}
export interface SlackFileUploadAcceptedReceipt {
version: 1;
channelId: string;
fileIds: string[];
threadTs: string | null;
}
interface SlackAdapterInternals {
paperclipFileUploadReceiptContext?: AsyncLocalStorage<
(receipt: SlackFileUploadAcceptedReceipt) => Promise<void>
>;
paperclipResolveFileUploadReceipt?: (
fileIds: string[],
threadId: string,
) => Promise<string | null>;
}
interface DiscordAdapterInternals {
ensureRootThread?: unknown;
paperclipCompatibilityRevision?: unknown;
startGatewayListener?: unknown;
}
interface DiscordChatInternals {
handleActionEvent?: unknown;
handleIncomingMessage?: unknown;
handleReactionEvent?: unknown;
processMessageDeleted?: unknown;
processMessageUpdated?: unknown;
}
interface DiscordCommandDispatch {
raw: unknown;
response?: unknown;
}
interface DiscordCommandInteraction {
id: string;
applicationId: string;
commandId: string;
commandName: string;
commandType: number;
type: number;
context: number | null;
guildId: string | null;
channelId: string | null;
channel: { id: string; type: number; parentId?: string } | null;
authorizingIntegrationOwners: {
guildId: string | null;
userId: string | null;
};
user: { id: string; bot?: boolean };
options: {
data: Array<{
name: string;
type: number;
options?: unknown[];
value?: unknown;
}>;
};
createdTimestamp: number;
replied: boolean;
deferred: boolean;
isChatInputCommand(): boolean;
deferReply(options: { flags: number }): Promise<unknown>;
editReply(options: {
content: string;
allowedMentions: { parse: never[] };
}): Promise<unknown>;
}
interface DiscordCommandAdapter {
handleGatewayInteraction(
interaction: DiscordCommandInteraction,
): Promise<void>;
normalizeGatewaySlashCommandInteraction(
interaction: DiscordCommandInteraction,
): Record<string, unknown>;
getApplicationCommandContext(raw: Record<string, unknown>): {
channelId: string;
user: { id: string; username: string; global_name?: string; bot?: boolean };
} | null;
}
/**
* The pinned adapter's slash path is fire-and-forget and redirects postMessage
* into interaction webhooks. Keep this opt-in path outside that requestContext,
* while still using its channel normalization and Chat's real awaited dispatch.
*/
function installDiscordNativeCommands(
adapter: Adapter,
chat: Chat,
applicationId: string,
guildId: string,
dispatchContext: AsyncLocalStorage<DiscordCommandDispatch>,
): void {
const discord = adapter as unknown as DiscordCommandAdapter;
const sdk = chat as unknown as {
handleSlashCommandEvent(event: Record<string, unknown>): Promise<void>;
};
if (
typeof discord.handleGatewayInteraction !== "function" ||
typeof discord.normalizeGatewaySlashCommandInteraction !== "function" ||
typeof discord.getApplicationCommandContext !== "function" ||
typeof sdk.handleSlashCommandEvent !== "function"
) {
throw new DiscordAdapterCompatibilityError(
"awaited native command dispatch is unavailable",
);
}
const original = discord.handleGatewayInteraction.bind(discord);
// Only IDs/timestamps are retained, never interaction objects or their tokens.
// This suppresses same-process redelivery; service receipt dedupe is still
// required across restarts. Expired snowflakes are never acknowledged anew.
const attempted = new Map<string, number>();
const snowflake = (value: unknown): value is string =>
typeof value === "string" && /^[1-9][0-9]{16,19}$/.test(value);
const denied =
"This command is not available here. Open the Paperclip task or ask an operator to check your chat access.";
const unconfirmed =
"This command could not be confirmed. Check the Paperclip task before trying again.";
discord.handleGatewayInteraction = async (interaction) => {
if (!interaction.isChatInputCommand()) return await original(interaction);
const startedAt = Date.now();
for (const [id, expiresAt] of attempted)
if (expiresAt <= startedAt) attempted.delete(id);
if (
!snowflake(interaction.id) ||
attempted.has(interaction.id) ||
attempted.size >= 1024
)
return;
// Reserve 500ms for the initial REST response. No database work precedes it.
if (
!Number.isFinite(interaction.createdTimestamp) ||
startedAt >= interaction.createdTimestamp + 2500 ||
interaction.createdTimestamp > startedAt + 1000
)
return;
attempted.set(interaction.id, interaction.createdTimestamp + 15 * 60_000);
if (interaction.deferred || interaction.replied) return;
try {
await interaction.deferReply({ flags: 64 });
} catch {
// The initial write may have succeeded. Never retry it, call the service,
// send a second initial response, or expose provider error/token details.
return;
}
let content = denied;
const options = interaction.options?.data;
const option =
Array.isArray(options) && options.length === 1 ? options[0] : undefined;
const isGuild =
interaction.context === 0 &&
interaction.guildId === guildId &&
interaction.authorizingIntegrationOwners?.guildId === guildId;
const isDm =
interaction.context === 1 &&
interaction.guildId === null &&
interaction.authorizingIntegrationOwners?.guildId === "0" &&
interaction.channel?.type === 1;
const channel = interaction.channel;
const thread = channel?.type === 11 || channel?.type === 12;
const valid =
interaction.type === 2 &&
interaction.commandType === 1 &&
interaction.applicationId === applicationId &&
interaction.commandName === "paperclip" &&
snowflake(interaction.commandId) &&
snowflake(interaction.user?.id) &&
!interaction.user.bot &&
interaction.user.id !== applicationId &&
!interaction.authorizingIntegrationOwners?.userId &&
(isGuild || isDm) &&
snowflake(interaction.channelId) &&
channel?.id === interaction.channelId &&
(!thread || snowflake(channel?.parentId)) &&
option?.type === 1 &&
["status", "new", "close"].includes(option.name) &&
option.value === undefined &&
(option.options === undefined ||
(Array.isArray(option.options) && option.options.length === 0));
if (valid && option) {
const normalized =
discord.normalizeGatewaySlashCommandInteraction(interaction);
// Explicit allowlist: the adapter's normalized raw currently has a token.
// Do not pass it (or the discord.js interaction object) to Chat or storage.
const raw = {
id: interaction.id,
application_id: applicationId,
type: 2,
version: 1,
channel: normalized.channel,
channel_id: interaction.channelId,
guild_id: interaction.guildId ?? "@me",
user: normalized.user,
context: interaction.context,
authorizing_integration_owners: { "0": isGuild ? guildId : "0" },
data: {
id: interaction.commandId,
type: 1,
name: "paperclip",
options: [{ type: 1, name: option.name }],
},
};
const context = discord.getApplicationCommandContext(raw);
if (context) {
const dispatch: DiscordCommandDispatch = { raw };
content = unconfirmed;
try {
await dispatchContext.run(dispatch, () =>
sdk.handleSlashCommandEvent({
command: `/paperclip ${option.name}`,
text: "",
adapter,
raw,
channelId: context.channelId,
user: {
userId: context.user.id,
userName: context.user.username,
fullName: context.user.global_name || context.user.username,
isBot: false,
isMe: false,
},
}),
);
const response = dispatch.response;
if (
response &&
typeof response === "object" &&
!Array.isArray(response)
) {
const record = response as Record<string, unknown>;
if (record.kind === "denied" && Object.keys(record).length === 1)
content = denied;
else if (
record.kind === "accepted" &&
Object.keys(record).length === 2 &&
typeof record.content === "string" &&
record.content.trim().length > 0 &&
record.content.length <= 2000 &&
!record.content.includes("\0")
)
content = record.content;
}
} catch {
// The application may already have committed. No synthetic success or
// raw exception text, and no replay of the command in this adapter.
}
}
}
if (Date.now() >= interaction.createdTimestamp + 14 * 60_000) return;
try {
await interaction.editReply({ content, allowedMentions: { parse: [] } });
} catch {
/* Outcome unknown; no second response or callback replay. */
}
};
}
function assertDiscordAdapterCompatibility(adapter: Adapter, chat: Chat): void {
const discord = adapter as unknown as DiscordAdapterInternals;
if (discord.paperclipCompatibilityRevision !== "paperclip-discord-v6") {
throw new DiscordAdapterCompatibilityError(
"Paperclip patch revision paperclip-discord-v6 is unavailable",
);
}
if (typeof discord.startGatewayListener !== "function") {
throw new DiscordAdapterCompatibilityError(
"startGatewayListener is unavailable",
);
}
if (typeof discord.ensureRootThread !== "function") {
throw new DiscordAdapterCompatibilityError(
"ensureRootThread is unavailable",
);
}
const internals = chat as unknown as DiscordChatInternals;
for (const method of [
"handleActionEvent",
"handleIncomingMessage",
"handleReactionEvent",
"processMessageDeleted",
"processMessageUpdated",
] as const) {
if (typeof internals[method] !== "function") {
throw new DiscordAdapterCompatibilityError(
`Chat.${method} is unavailable`,
);
}
}
}
interface TeamsApiClientInternals {
_apiClientSettings?: unknown;
constructor: Function;
http: unknown;
serviceUrl: string;
}
interface TeamsAdapterInternals {
app?: {
api: TeamsApiClientInternals;
id?: unknown;
on?: TeamsConsentApp["on"];
send?: (conversationId: string, activity: unknown) => Promise<unknown>;
};
chat?: {
getState(): {
get(key: string): Promise<unknown>;
set(key: string, value: string, ttlMs?: number): Promise<void>;
};
};
decodeThreadId?: (threadId: string) => {
conversationId?: unknown;
conversationType?: unknown;
serviceUrl?: unknown;
};
cacheUserContext?: (activity: unknown) => void;
getIncomingUser?: (...args: unknown[]) => Promise<unknown>;
getUser?: (...args: unknown[]) => Promise<unknown>;
openDM?: (userId: string) => Promise<unknown>;
paperclipRecordAcceptedActivity?: (activity: unknown) => Promise<void>;
paperclipRecordThreadServiceUrl?: (
threadId: string,
serviceUrl: unknown,
) => Promise<void>;
paperclipSendFileCard?: (
threadId: string,
kind: "consent" | "file_info",
card: unknown,
) => Promise<{ id: string }>;
[key: string]: unknown;
}
function teamsConversationRouteStateKey(conversationId: string): string {
const baseConversationId = conversationId.replace(/;messageid=[^;]+/i, "");
return `teams:serviceUrl:conversation:${Buffer.from(baseConversationId).toString("base64url")}`;
}
const TEAMS_ACCEPTED_ACTIVITY_CACHE_TTL_MS = 30 * 24 * 60 * 60 * 1_000;
function normalizedTeamsServiceUrl(value: unknown): string {
if (typeof value !== "string" || value.length > 2048) {
throw new TeamsServiceUrlValidationError(
"Teams destination is missing its verified service URL",
);
}
let parsed: URL;
try {
parsed = new URL(value);
} catch {
throw new TeamsServiceUrlValidationError(
"Teams destination contains an invalid service URL",
);
}
if (
parsed.protocol !== "https:" ||
parsed.username ||
parsed.password ||
parsed.search ||
parsed.hash
) {
throw new TeamsServiceUrlValidationError(
"Teams destination contains an invalid service URL",
);
}
return parsed.toString().replace(/\/+$/, "");
}
function trustedTeamsServiceUrl(
value: unknown,
configuredApiUrl: string | null,
): string {
const rawValue = typeof value === "string" ? value : "";
const normalized = normalizedTeamsServiceUrl(value);
const parsed = new URL(normalized);
const rawParsed = new URL(rawValue);
const officialHost =
rawValue === rawValue.trim() &&
parsed.port === "" &&
isOfficialTeamsConnectorHost(parsed.hostname) &&
// Encoded path bytes can be normalized away by URL parsing. Microsoft
// connector base URLs do not require them, so reject them before applying
// the canonical one-segment path policy.
!/%[0-9a-f]{2}/i.test(rawValue) &&
isCanonicalTeamsConnectorPath(rawParsed.pathname);
if (officialHost || normalized === configuredApiUrl) return normalized;
throw new TeamsServiceUrlValidationError(
"Teams destination contains an untrusted service URL",
);
}
/**
* Microsoft binds serviceUrl into the authenticated Bot Connector JWT and
* requires replies to target that matching URL. The URL is mutable routing
* state, not conversation identity, so current thread ids omit it. Persist the
* latest verified route under the stable conversation id and scope each
* outbound call to a fresh API client rooted at that route. Legacy thread ids
* that embedded a URL remain readable, but a newer persisted route wins. A
* context-local getter keeps simultaneous conversations isolated without
* forcing unrelated Teams threads through a single network queue.
*/
export function scopeMicrosoftTeamsEgress(
adapter: Adapter,
configuredApiUrl?: string,
enableFileConsent = false,
): Adapter {
const teams = adapter as unknown as TeamsAdapterInternals;
if (!teams.app?.api) {
throw new TeamsAdapterCompatibilityError("app.api is unavailable");
}
if (typeof teams.decodeThreadId !== "function") {
throw new TeamsAdapterCompatibilityError("decodeThreadId is unavailable");
}
if (typeof teams.openDM !== "function") {
throw new TeamsAdapterCompatibilityError("openDM is unavailable");
}
if (typeof teams.cacheUserContext !== "function") {
throw new TeamsAdapterCompatibilityError("cacheUserContext is unavailable");
}
if (typeof teams.getIncomingUser !== "function") {
throw new TeamsAdapterCompatibilityError("getIncomingUser is unavailable");
}
if (typeof teams.getUser !== "function") {
throw new TeamsAdapterCompatibilityError("getUser is unavailable");
}
for (const methodName of TEAMS_THREAD_SCOPED_METHODS) {
if (typeof teams[methodName] !== "function") {
throw new TeamsAdapterCompatibilityError(`${methodName} is unavailable`);
}
}
if (
typeof teams.app.api.constructor !== "function" ||
!("http" in teams.app.api)
) {
throw new TeamsAdapterCompatibilityError(
"the API client constructor or HTTP transport is unavailable",
);
}
const apiDescriptor = Object.getOwnPropertyDescriptor(teams.app, "api");
if (apiDescriptor && apiDescriptor.configurable === false) {
throw new TeamsAdapterCompatibilityError(
"app.api cannot be scoped per asynchronous conversation",
);
}
const trustedConfiguredApiUrl = configuredApiUrl
? normalizedTeamsServiceUrl(configuredApiUrl)
: null;
// The pinned Teams adapter caches activity/user metadata and may query the
// members or Graph APIs before Chat dispatches to Paperclip's reach and
// tenant checks. Keep authenticated but unadmitted events observationally
// inert. Accepted activities explicitly persist the minimum routing context
// through paperclipRecordAcceptedActivity below.
teams.cacheUserContext = () => {};
teams.getIncomingUser = async () => null;
teams.getUser = async () => null;
teams.paperclipRecordAcceptedActivity = async (activityValue: unknown) => {
if (!isRecord(activityValue)) return;
const from = isRecord(activityValue.from) ? activityValue.from : null;
const conversation = isRecord(activityValue.conversation)
? activityValue.conversation
: null;
const channelData = isRecord(activityValue.channelData)
? activityValue.channelData
: null;
const userId =
typeof from?.id === "string" && from.id.length > 0 ? from.id : null;
if (!userId) return;
const state = teams.chat?.getState();
if (!state) {
throw new TeamsAdapterCompatibilityError(
"durable accepted-activity state is unavailable",
);
}
const ttl = TEAMS_ACCEPTED_ACTIVITY_CACHE_TTL_MS;
const writes: Promise<void>[] = [];
if (activityValue.serviceUrl !== undefined) {
writes.push(
state.set(
`teams:serviceUrl:${userId}`,
trustedTeamsServiceUrl(
activityValue.serviceUrl,
trustedConfiguredApiUrl,
),
ttl,
),
);
}
if (typeof from?.aadObjectId === "string" && from.aadObjectId.length > 0) {
writes.push(
state.set(`teams:aadObjectId:${userId}`, from.aadObjectId, ttl),
);
}
const tenantId = microsoftTeamsTenantIds(activityValue)[0];
if (tenantId) {
writes.push(state.set(`teams:tenantId:${userId}`, tenantId, ttl));
}
const conversationId =
typeof conversation?.id === "string" ? conversation.id : "";
const baseConversationId = conversationId.replace(/;messageid=[^;]+/i, "");
const conversationType =
typeof conversation?.conversationType === "string"
? conversation.conversationType
: null;
const isPersonalConversation = conversationType
? conversationType === "personal"
: !baseConversationId.startsWith("19:");
const team =
channelData && isRecord(channelData.team) ? channelData.team : null;
const channel =
channelData && isRecord(channelData.channel) ? channelData.channel : null;
if (
baseConversationId &&
!isPersonalConversation &&
typeof team?.aadGroupId === "string" &&
team.aadGroupId.length > 0 &&
typeof channel?.id === "string" &&
channel.id.length > 0
) {
writes.push(
state.set(
`teams:channelContext:${baseConversationId}`,
JSON.stringify({
teamId: team.aadGroupId,
channelId: channel.id,
}),
ttl,
),
);
}
if (
baseConversationId &&
typeof from?.aadObjectId === "string" &&
from.aadObjectId.length > 0 &&
isPersonalConversation
) {
const appId = teams.app?.id;
if (typeof appId === "string" && appId.length > 0) {
writes.push(
state.set(
`teams:channelContext:${baseConversationId}`,
JSON.stringify({
type: "dm",
graphChatId: `19:${from.aadObjectId}_${appId}@unq.gbl.spaces`,
}),
ttl,
),
);
}
}
await Promise.all(writes);
};
let defaultApi = teams.app.api;
const apiScope = new AsyncLocalStorage<TeamsApiClientInternals>();
Object.defineProperty(teams.app, "api", {
configurable: true,
enumerable: true,
get: () => apiScope.getStore() ?? defaultApi,
set: (value: TeamsApiClientInternals) => {
defaultApi = value;
},
});
const withServiceUrl = async <T>(
serviceUrlValue: unknown,
operation: () => Promise<T>,
): Promise<T> => {
const serviceUrl = trustedTeamsServiceUrl(
serviceUrlValue,
trustedConfiguredApiUrl,
);
const ApiClient = defaultApi.constructor as new (
serviceUrl: string,
http: unknown,
settings?: unknown,
) => TeamsApiClientInternals;
const scopedApi = new ApiClient(
serviceUrl,
defaultApi.http,
defaultApi._apiClientSettings,
);
return await apiScope.run(scopedApi, operation);
};
const withThreadServiceUrl = async <T>(
threadId: string,
operation: () => Promise<T>,
requireRoute = false,
): Promise<T> => {
const decoded = teams.decodeThreadId!(threadId);
if (typeof decoded.conversationId !== "string" || !decoded.conversationId) {
throw new TeamsServiceUrlValidationError(
"Teams destination is missing its conversation identity",
);
}
const persistedServiceUrl = await teams.chat
?.getState()
.get(teamsConversationRouteStateKey(decoded.conversationId));
if (
requireRoute &&
persistedServiceUrl == null &&
decoded.serviceUrl == null
) {
throw new TeamsServiceUrlValidationError(
"Teams file destination is missing its verified route",
);
}
return await withServiceUrl(
persistedServiceUrl ?? decoded.serviceUrl ?? defaultApi.serviceUrl,
operation,
);
};
if (enableFileConsent) {
if (
typeof teams.app.send !== "function" ||
typeof teams.app.on !== "function"
) {
throw new TeamsAdapterCompatibilityError(
"file-consent App hooks are unavailable",
);
}
teams.paperclipSendFileCard = async (threadId, kind, input) => {
const card =
kind === "consent"
? parseTeamsFileConsentCard(input)
: kind === "file_info"
? parseTeamsUploadedFileCard(input)
: null;
if (!card)
throw new TeamsAdapterCompatibilityError("invalid file-card shape");
const decoded = teams.decodeThreadId!(threadId);
// Never infer personal scope from a missing type or conversation prefix.
if (
decoded.conversationType !== "personal" ||
typeof decoded.conversationId !== "string" ||
!decoded.conversationId ||
decoded.conversationId.length > 1024 ||
/[\x00-\x20\x7f]/.test(decoded.conversationId) ||
/;messageid=/i.test(decoded.conversationId)
) {
throw new TeamsServiceUrlValidationError(
"Teams file cards require an exact personal conversation",
);
}
return await withThreadServiceUrl(
threadId,
async () => {
let result: unknown;
try {
// Direct App attachment send is deliberately inside the same route
// ALS as ordinary thread operations. Thread.post would turn these
// provider-native cards into AdaptiveCards.
result = await teams.app!.send!(decoded.conversationId as string, {
type: "message",
attachments: [card],
});
} catch {
// Provider errors may contain private card contexts. No implicit
// retry: an uncertain POST remains caller-owned durable evidence.
throw new Error("Teams file-card send result is unknown");
}
if (
!isRecord(result) ||
typeof result.id !== "string" ||
!result.id ||
result.id.length > 1024 ||
/[\x00-\x20\x7f]/.test(result.id)
) {
throw new Error("Teams file-card send receipt is unproven");
}
return { id: result.id };
},
true,
);
};
}
teams.paperclipRecordThreadServiceUrl = async (
threadId: string,
serviceUrlValue: unknown,
) => {
const decoded = teams.decodeThreadId!(threadId);
if (typeof decoded.conversationId !== "string" || !decoded.conversationId) {
throw new TeamsServiceUrlValidationError(
"Teams destination is missing its conversation identity",
);
}
const serviceUrl = trustedTeamsServiceUrl(
serviceUrlValue,
trustedConfiguredApiUrl,
);
const state = teams.chat?.getState();
if (!state) {
throw new TeamsAdapterCompatibilityError(
"durable route state is unavailable",
);
}
await state.set(
teamsConversationRouteStateKey(decoded.conversationId),
serviceUrl,
);
};
for (const methodName of TEAMS_THREAD_SCOPED_METHODS) {
const original = teams[methodName];
if (typeof original !== "function") continue;
teams[methodName] = (threadId: string, ...args: unknown[]) =>
withThreadServiceUrl(threadId, async () =>
Reflect.apply(original, teams, [threadId, ...args]),
);
}
const originalOpenDM = teams.openDM;
teams.openDM = async (userId: string) => {
const cachedServiceUrl = await teams.chat
?.getState()
.get(`teams:serviceUrl:${userId}`);
const serviceUrl = cachedServiceUrl ?? defaultApi.serviceUrl;
return await withServiceUrl(serviceUrl, async () => {
const threadId = await Reflect.apply(originalOpenDM, teams, [userId]);
if (typeof threadId === "string") {
await teams.paperclipRecordThreadServiceUrl!(threadId, serviceUrl);
}
return threadId;
});
};
return adapter;
}
function createProviderAdapter(
config: ResolvedChatSdkProviderConfig,
logger: CreateChatSdkEndpointRuntimeOptions["logger"],
callbacks: ChatSdkRuntimeCallbacks,
endpointId: string,
observeDiscordGatewayFatal?: (fatal: boolean) => void,
): Adapter {
const resolvedLogger = adapterLogger(logger);
switch (config.provider) {
case "imessage-photon": throw new Error("Photon adapter requires scoped persistence");
case "slack": {
const adapterConfig: SlackAdapterConfig = {
...config.credentials,
webhookVerifier: async (request, body) => {
await verifySlackSignature(body, request.headers, {
signingSecret: config.credentials.signingSecret,
now: () => slackCloudAcceptedAt(request) ?? Date.now(),
});
return true;
},
logger: resolvedLogger,
mode: "webhook",
nativeStreaming: true,
userName: config.userName,
// Paperclip's durable publication outbox owns retry timing and
// ambiguous-delivery handling. Slack's default client can otherwise
// retry for roughly 30 minutes, well beyond the 60-second streaming
// lease, allowing another worker to quarantine an in-flight send.
webClientOptions: {
rejectRateLimitedCalls: true,
retryConfig: { retries: 0 },
timeout: SLACK_WEB_API_TIMEOUT_MS,
},
};
const adapter = new PaperclipSlackAdapter(adapterConfig);
(
adapter as unknown as SlackAdapterInternals
).paperclipFileUploadReceiptContext = new AsyncLocalStorage<
(receipt: SlackFileUploadAcceptedReceipt) => Promise<void>
>();
return adapter;
}
case "github": {
// GitHub's API identifies an App actor as `<slug>[bot]`, while people
// invoke the App in issue and PR comments with `@<slug>`. The shared
// mention detector deliberately supports the bracketed actor form when
// configured with the bare slug, but the inverse is not true.
const mentionUserName = config.userName.replace(/\[bot\]$/i, "");
const adapterConfig = {
...config.credentials,
logger: resolvedLogger,
userName: mentionUserName,
} as GitHubAdapterConfig;
const adapter = createGitHubAdapter(adapterConfig);
// Octokit otherwise delegates to fetch without a deadline. A hung token
// exchange or API request could outlive Paperclip's publication lease
// and make another worker quarantine a still-running send as ambiguous.
// Wrap every request with a fresh deadline instead of creating one at
// adapter construction time, which would expire for the whole runtime.
adapter.octokit?.hook?.wrap?.("request", async (request, options) => {
const timeoutSignal = AbortSignal.timeout(GITHUB_API_TIMEOUT_MS);
const existingSignal = options.request?.signal;
const signal = existingSignal
? AbortSignal.any([existingSignal, timeoutSignal])
: timeoutSignal;
let rejectOnAbort!: () => void;
const aborted = new Promise<never>((_resolve, reject) => {
rejectOnAbort = () =>
reject(signal.reason ?? new Error("GitHub API request timed out"));
if (signal.aborted) rejectOnAbort();
else signal.addEventListener("abort", rejectOnAbort, { once: true });
});
try {
// The race also bounds Octokit's internal installation-token
// exchange, which does not inherit the final API request's signal.
options.request = { ...options.request, signal };
return await Promise.race([request(options), aborted]);
} finally {
signal.removeEventListener("abort", rejectOnAbort);
}
});
return adapter;
}
case "discord": {
let gatewaySequence = 0;
const adapterConfig: DiscordAdapterConfig = {
apiUrl: config.credentials.apiUrl,
applicationId: config.credentials.applicationId,
botToken: config.credentials.botToken,
// Paperclip receives Discord messages and interactions over its
// authenticated Gateway session. Keep the unused public HTTP
// interaction surface closed instead of making setup collect a key it
// does not need.
webhookVerifier: async () => false,
logger: resolvedLogger,
onGatewayEvent: async (event) => {
if (
"guildId" in event &&
(!event.guildId || event.guildId !== config.credentials.guildId)
) {
return;
}
const fatal =
((event.type === "failure" || event.type === "disconnected") &&
event.fatal === true) ||
event.type === "guild_removed";
await callbacks.onDiscordGatewayEvent?.({
endpointId,
event,
provider: "discord",
sequence: ++gatewaySequence,
});
if (fatal) observeDiscordGatewayFatal?.(true);
else if (event.type === "ready") observeDiscordGatewayFatal?.(false);
},
shouldCreateThread: async (input) => {
if (input.guildId !== config.credentials.guildId) return false;
return (
(await callbacks.onDiscordRootMentionAdmission?.({
...input,
endpointId,
})) ?? false
);
},
userName: config.userName,
};
return createDiscordAdapter(adapterConfig);
}
case "microsoft-teams": {
const adapterConfig: TeamsAdapterConfig = {
...config.credentials,
logger: resolvedLogger,
userName: config.userName,
};
return scopeMicrosoftTeamsEgress(
createTeamsAdapter(adapterConfig),
config.credentials.apiUrl,
typeof callbacks.onTeamsFileConsent === "function",
);
}
case "telegram": {
const adapterConfig: TelegramAdapterConfig = {
...config.credentials,
logger: resolvedLogger,
maxDownloadBytes: config.maxDownloadBytes,
mentionOnReply: true,
mode: "webhook",
nativeStreaming: true,
userName: config.userName,
};
const adapter = createTelegramAdapter(adapterConfig);
const parser = adapter as unknown as {
extractAttachments(raw: TelegramRawMessage): Attachment[];
parseTelegramMessage(
raw: TelegramRawMessage,
threadId: string,
content?: { text: string; formatted: Message["formatted"] },
): Message;
createAttachment(
type: Attachment["type"],
fileId: string,
metadata: Record<string, unknown>,
): Attachment;
};
if (
typeof parser.extractAttachments !== "function" ||
typeof parser.createAttachment !== "function" ||
typeof parser.parseTelegramMessage !== "function"
) {
throw new Error("Telegram attachment parser contract is unavailable");
}
const extractAttachments = parser.extractAttachments.bind(adapter);
const create = (
type: Attachment["type"],
fileId: string,
metadata: Record<string, unknown>,
) => parser.createAttachment(type, fileId, metadata);
parser.extractAttachments = (raw) =>
normalizeTelegramRichMessage(raw, create)?.attachments ??
normalizeTelegramMediaAttachments(
raw,
normalizeTelegramVideoNoteAttachments(raw, extractAttachments(raw)),
create,
);
const parseTelegramMessage = parser.parseTelegramMessage.bind(adapter);
parser.parseTelegramMessage = (raw, threadId, content) => {
const rich = normalizeTelegramRichMessage(raw, create);
if (!rich) return parseTelegramMessage(raw, threadId, content);
// Remove the rich tree before the legacy converter can recursively
// traverse it. Keep provider identity/raw only for verified source
// binding; exported text/AST contains the bounded readable projection.
const safeRaw = {
...raw,
rich_message: undefined,
text: rich.text,
caption: undefined,
entities: [],
caption_entities: [],
reply_to_message: undefined,
} as TelegramRawMessage;
const message = parseTelegramMessage(safeRaw, threadId, {
text: rich.text,
formatted: {
type: "root",
children: [
{
type: "paragraph",
children: [{ type: "text", value: rich.text }],
},
],
},
});
message.raw = raw;
message.attachments = rich.attachments;
return message;
};
return adapter;
}
}
}
function createAttachmentRecoveryDescriptor(
provider: ChatSdkProvider,
attachment: Attachment,
): ChatSdkAttachmentRecoveryDescriptor | null {
const metadata = durableAttachmentMetadata(attachment);
if (!metadata) return null;
const fetchMetadata = attachment.fetchMetadata ?? {};
if (provider === "github") {
const locator = githubAttachmentLocator(attachment);
return locator
? { version: 1, provider, attachment: metadata, locator }
: null;
}
if (provider === "slack") {
const url = sanitizedRecoveryUrl(fetchMetadata.url ?? attachment.url);
if (!url) return null;
const teamId = boundedAttachmentText(fetchMetadata.teamId, 512);
const enterpriseId = boundedAttachmentText(fetchMetadata.enterpriseId, 512);
return {
version: 1,
provider,
attachment: metadata,
locator: {
kind: "slack_private_url",
url,
...(teamId ? { teamId } : {}),
...(enterpriseId ? { enterpriseId } : {}),
...(fetchMetadata.isEnterpriseInstall === "true"
? { isEnterpriseInstall: true }
: {}),
},
};
}
if (provider === "microsoft-teams") {
const rawUrl = fetchMetadata.url ?? attachment.url;
if (fetchMetadata.auth === "bot") {
const connectorOrigin = sanitizedRecoveryOrigin(
fetchMetadata.connectorOrigin,
);
if (!connectorOrigin) return null;
const url = sanitizedRecoveryUrl(rawUrl, {
requiredOrigin: connectorOrigin,
});
if (!url) return null;
return {
version: 1,
provider,
attachment: metadata,
locator: { kind: "teams_bot_url", url, connectorOrigin },
};
}
// Anonymous Teams download URLs can be short-lived bearer URLs. Only a
// query-free HTTPS locator is safe to persist in ordinary delivery JSON.
const url = sanitizedRecoveryUrl(rawUrl);
if (!url) return null;
return {
version: 1,
provider,
attachment: metadata,
locator: { kind: "teams_anonymous_url", url },
};
}
if (provider === "discord") {
const url = sanitizedRecoveryUrl(
attachment.fetchMetadata?.url ?? attachment.url,
{ allowQuery: true },
);
if (!url) return null;
const hostname = new URL(url).hostname.toLowerCase();
if (
hostname !== "cdn.discordapp.com" &&
hostname !== "media.discordapp.net"
)
return null;
return {
version: 1,
provider,
attachment: metadata,
locator: { kind: "discord_cdn_url", url },
};
}
if (provider === "telegram") {
const fileId = boundedAttachmentText(fetchMetadata.fileId, 2048);
if (!fileId) return null;
const fileUniqueId = boundedAttachmentText(
fetchMetadata.fileUniqueId,
2048,
);
return {
version: 1,
provider,
attachment: metadata,
locator: {
kind: "telegram_file_id",
fileId,
...(fileUniqueId ? { fileUniqueId } : {}),
},
};
}
return null;
}
function validatedAttachmentRecoveryDescriptor(
provider: ChatSdkProvider,
value: unknown,
): ChatSdkAttachmentRecoveryDescriptor | null {
if (!isRecord(value) || value.version !== 1 || value.provider !== provider)
return null;
if (!isRecord(value.attachment) || !isRecord(value.locator)) return null;
const metadata = value.attachment as unknown as Attachment;
const kind = value.locator.kind;
if (provider === "github" && kind === "github_public_attachment") {
const locator = validateGitHubAttachmentLocator(value.locator);
const attachment = durableAttachmentMetadata(metadata);
return locator && attachment
? { version: 1, provider, attachment, locator }
: null;
}
if (provider === "slack" && kind === "slack_private_url") {
return createAttachmentRecoveryDescriptor(provider, {
...metadata,
fetchMetadata: {
url: value.locator.url as string,
...(typeof value.locator.teamId === "string"
? { teamId: value.locator.teamId }
: {}),
...(typeof value.locator.enterpriseId === "string"
? { enterpriseId: value.locator.enterpriseId }
: {}),
...(value.locator.isEnterpriseInstall === true
? { isEnterpriseInstall: "true" }
: {}),
},
});
}
if (provider === "microsoft-teams" && kind === "teams_bot_url") {
return createAttachmentRecoveryDescriptor(provider, {
...metadata,
fetchMetadata: {
auth: "bot",
url: value.locator.url as string,
connectorOrigin: value.locator.connectorOrigin as string,
},
});
}
if (provider === "discord" && kind === "discord_cdn_url") {
return createAttachmentRecoveryDescriptor(provider, {
...metadata,
fetchMetadata: { url: value.locator.url as string },
});
}
if (provider === "microsoft-teams" && kind === "teams_anonymous_url") {
return createAttachmentRecoveryDescriptor(provider, {
...metadata,
fetchMetadata: { url: value.locator.url as string },
});
}
if (provider === "telegram" && kind === "telegram_file_id") {
return createAttachmentRecoveryDescriptor(provider, {
...metadata,
fetchMetadata: {
fileId: value.locator.fileId as string,
...(typeof value.locator.fileUniqueId === "string"
? { fileUniqueId: value.locator.fileUniqueId }
: {}),
},
});
}
return null;
}
interface WebhookIngressAttempt {
receivedAtMs: number;
callbackError: unknown;
callbackPromises: Set<Promise<unknown>>;
providerUpdateId?: number;
}
async function telegramWebhookUpdateId(
request: Request,
): Promise<number | undefined> {
try {
const payload = (await request.clone().json()) as unknown;
if (!isRecord(payload)) return undefined;
const updateId = payload.update_id;
return typeof updateId === "number" &&
Number.isSafeInteger(updateId) &&
updateId >= 0
? updateId
: undefined;
} catch {
return undefined;
}
}
/**
* Chat SDK's message and provider-level retry markers are written before the
* application callback runs. That ordering is unsafe for Paperclip: if the
* `chat_deliveries` insert fails, a provider retry can be discarded by the SDK
* even though Paperclip never durably accepted it. Paperclip's delivery ledger
* is the authoritative dedupe boundary, so these early SDK markers are
* deliberately bypassed while subscriptions and all other adapter state stay
* durable.
*/
function paperclipAuthoritativeIngressState(
state: StateAdapter,
recordFailure: (error: unknown) => void,
): StateAdapter {
const isCoreMessageDedupe = (key: string) => key.startsWith("dedupe:");
const isSlackDeliveryMarker = (key: string) =>
key.startsWith("slack:event-delivered:");
const isTelegramDeliveryMarker = (key: string) =>
key.startsWith("telegram:webhook-update:");
return new Proxy(state, {
get(target, property, receiver) {
if (property === "setIfNotExists") {
return async (key: string, value: unknown, ttlMs?: number) => {
if (isCoreMessageDedupe(key) || isTelegramDeliveryMarker(key)) {
return true;
}
try {
return await target.setIfNotExists(key, value, ttlMs);
} catch (error) {
recordFailure(error);
throw error;
}
};
}
if (property === "set") {
return async (key: string, value: unknown, ttlMs?: number) => {
if (isSlackDeliveryMarker(key)) return;
try {
await target.set(key, value, ttlMs);
} catch (error) {
recordFailure(error);
throw error;
}
};
}
if (property === "get") {
return async <T = unknown>(key: string): Promise<T | null> => {
if (isSlackDeliveryMarker(key)) return null;
try {
return await target.get<T>(key);
} catch (error) {
recordFailure(error);
throw error;
}
};
}
const value = Reflect.get(target, property, receiver) as unknown;
if (typeof value !== "function") return value;
return (...args: unknown[]) => {
try {
const result = Reflect.apply(value, target, args) as unknown;
if (!(result instanceof Promise)) return result;
return result.catch((error: unknown) => {
recordFailure(error);
throw error;
});
} catch (error) {
recordFailure(error);
throw error;
}
};
},
});
}
function registerCallbacks(
chat: Chat,
endpointId: string,
provider: ChatSdkProvider,
callbacks: ChatSdkRuntimeCallbacks,
trackCallback: <T>(callback: () => Promise<T> | T) => Promise<T>,
acceptsProviderScope: (raw: unknown) => boolean,
providerUpdateId: () => number | undefined,
actionTransport: () => ChatSdkCallbackEvent<ActionEvent>["transport"],
discordCommandDispatch: () => DiscordCommandDispatch | undefined,
telegramCallback: (raw: unknown) => TelegramCallbackProvenance | undefined,
): void {
const messageCallback =
(trigger: ChatSdkMessageTrigger) =>
async (
thread: Thread,
message: Message,
context?: MessageContext,
): Promise<void> => {
if (!acceptsProviderScope(message.raw)) return;
if (provider === "github" && message.attachments.length === 0) {
message.attachments.push(
...githubPublicAttachmentsFromMessage(message),
);
}
await trackCallback(
async () =>
await callbacks.onMessage({
endpointId,
providerUpdateId: providerUpdateId(),
provider,
trigger,
thread,
message,
context,
}),
);
};
chat.onDirectMessage(async (thread, message, _channel, context) => {
await messageCallback("direct_message")(thread, message, context);
});
chat.onNewMention(messageCallback("mention"));
chat.onSubscribedMessage(messageCallback("subscribed_message"));
// The core service records and applies policy to fresh unaddressed messages;
// the runtime deliberately does not subscribe or respond on its own.
chat.onNewMessage(/[\s\S]*/, messageCallback("unaddressed_message"));
if (callbacks.onMessageUpdated) {
chat.onMessageUpdated(async (thread, message, previousMessage) => {
if (!acceptsProviderScope(message.raw)) return;
await trackCallback(
async () =>
await callbacks.onMessageUpdated?.({
endpointId,
provider,
thread,
message,
previousMessage,
}),
);
});
}
if (callbacks.onMessageDeleted) {
chat.onMessageDeleted(async (event) => {
if (!acceptsProviderScope(event.raw)) return;
await trackCallback(
async () =>
await callbacks.onMessageDeleted?.({ endpointId, provider, event }),
);
});
}
if (callbacks.onReaction) {
chat.onReaction(async (event) => {
if (!acceptsProviderScope(event.raw)) return;
await trackCallback(
async () =>
await callbacks.onReaction?.({ endpointId, provider, event }),
);
});
}
if (callbacks.onAction) {
chat.onAction(async (sourceEvent) => {
if (provider === "slack" && isPassiveSlackAction(sourceEvent)) return;
const event = provider === "slack" ? normalizeSlackSelectionAction(sourceEvent) : sourceEvent;
const selectionValues = provider === "slack" ? slackSelectionValues(sourceEvent) : undefined;
const transport = actionTransport();
if (!acceptsProviderScope(event.raw)) {
if (transport === "discord_gateway") {
// Resolving would tell the Gateway adapter to acknowledge a click
// that never reached Paperclip's scoped action authorization.
throw Object.assign(
new Error("Discord Gateway action is outside the configured guild"),
{ code: "chat_discord_gateway_action_rejected" },
);
}
return;
}
await trackCallback(
async () =>
await callbacks.onAction?.({
endpointId,
provider,
event,
...(transport ? { transport } : {}),
...(selectionValues ? { selectionValues } : {}),
...(telegramCallback(event.raw)
? { telegramCallback: telegramCallback(event.raw) }
: {}),
}),
);
});
}
if (callbacks.onOptionsLoad) {
chat.onOptionsLoad(async (event) => {
if (!acceptsProviderScope(event.raw)) return undefined;
return await trackCallback(
async () =>
await callbacks.onOptionsLoad?.({ endpointId, provider, event }),
);
});
}
if (callbacks.onModalSubmit) {
chat.onModalSubmit(async (event) => {
if (!acceptsProviderScope(event.raw)) return undefined;
const transport = actionTransport();
return await trackCallback(
async () =>
await callbacks.onModalSubmit?.({
endpointId,
provider,
event,
...(transport ? { transport } : {}),
}),
);
});
}
if (callbacks.onModalClose) {
chat.onModalClose(async (event) => {
if (!acceptsProviderScope(event.raw)) return;
await trackCallback(
async () =>
await callbacks.onModalClose?.({ endpointId, provider, event }),
);
});
}
if (callbacks.onSlashCommand) {
chat.onSlashCommand(async (event) => {
const dispatch = discordCommandDispatch();
if (provider === "discord" && (!dispatch || dispatch.raw !== event.raw))
return;
if (!acceptsProviderScope(event.raw)) return;
const response = await trackCallback(
async () =>
await callbacks.onSlashCommand?.({
endpointId,
provider,
event,
...(dispatch ? { transport: "discord_gateway" as const } : {}),
}),
);
if (dispatch) dispatch.response = response;
});
}
}
/** One isolated Chat instance and provider adapter for one Paperclip endpoint. */
export class ChatSdkEndpointRuntime {
readonly companyId: string;
readonly endpointId: string;
readonly provider: ChatSdkProvider;
readonly sdkAdapterKey: ChatSdkAdapterKey;
private readonly adapter: Adapter;
private readonly chat: Chat;
private readonly webhookIngress =
new AsyncLocalStorage<WebhookIngressAttempt>();
private readonly discordCommandDispatch =
new AsyncLocalStorage<DiscordCommandDispatch>();
private readonly webhookIngressTimeoutMs: number;
private readonly slackReceiptBotToken: string | null;
private readonly githubReceiptApp: {
appId: string;
installationId: number;
} | null;
private readonly telegramEphemeralBotToken: string | null;
private readonly telegramCallbackProofs = new WeakMap<
object,
TelegramCallbackProvenance
>();
private readonly microsoftTeamsTenantId: string | null;
private readonly microsoftTeamsAppId: string | null;
private readonly teamsInlineImageDescriptors = new WeakMap<
Attachment,
ChatSdkAttachmentRecoveryDescriptor
>();
private readonly teamsInlineImageFetchers = new WeakMap<
Attachment,
(signal?: AbortSignal) => Promise<Buffer>
>();
private readonly discordGuildId: string | null;
private readonly discordGatewayEnabled: boolean;
private readonly githubAttachmentAppAuthority: boolean;
private readonly teamsFileConsentEnabled: boolean;
private discordGatewayAbort: AbortController | null = null;
private discordGatewayTask: Promise<void> | null = null;
private discordGatewayFatal = false;
private initialization: Promise<void> | null = null;
private retired = false;
private photonReceiver?: PhotonReceiver;
private readonly runtimeOptions: CreateChatSdkEndpointRuntimeOptions;
private shutdownTask: Promise<void> | null = null;
private shutdownCompleted = false;
constructor(options: CreateChatSdkEndpointRuntimeOptions) {
this.runtimeOptions = options;
this.companyId = options.companyId;
this.endpointId = options.endpointId;
this.provider = options.providerConfig.provider;
this.githubReceiptApp =
options.providerConfig.provider === "github" &&
"appId" in options.providerConfig.credentials &&
options.providerConfig.credentials.installationId &&
(!options.providerConfig.credentials.apiUrl ||
options.providerConfig.credentials.apiUrl === "https://api.github.com")
? {
appId: String(options.providerConfig.credentials.appId),
installationId: options.providerConfig.credentials.installationId,
}
: null;
this.slackReceiptBotToken =
options.providerConfig.provider === "slack"
? options.providerConfig.credentials.botToken
: null;
this.telegramEphemeralBotToken = options.providerConfig.provider === "telegram"
? options.providerConfig.credentials.botToken : null;
this.sdkAdapterKey = adapterKey(this.provider);
this.teamsFileConsentEnabled =
this.provider === "microsoft-teams" &&
typeof options.callbacks.onTeamsFileConsent === "function";
this.githubAttachmentAppAuthority =
options.providerConfig.provider === "github" &&
"appId" in options.providerConfig.credentials &&
Boolean(options.providerConfig.credentials.installationId) &&
(!options.providerConfig.credentials.apiUrl ||
options.providerConfig.credentials.apiUrl === "https://api.github.com");
this.microsoftTeamsTenantId =
options.providerConfig.provider === "microsoft-teams"
? options.providerConfig.credentials.appTenantId
?.trim()
.toLowerCase() || null
: null;
this.microsoftTeamsAppId =
options.providerConfig.provider === "microsoft-teams"
? options.providerConfig.credentials.appId
: null;
this.discordGuildId =
options.providerConfig.provider === "discord"
? options.providerConfig.credentials.guildId
: null;
this.discordGatewayEnabled = options.enableDiscordGateway !== false;
this.webhookIngressTimeoutMs = Math.max(
1,
Math.min(options.webhookIngressTimeoutMs ?? 2_500, 10_000),
);
this.adapter = options.providerConfig.provider === "imessage-photon"
? new PhotonChatAdapter(options.providerConfig.userName,
new PhotonLineAuthentication(options.providerConfig.credentials, options.providerConfig.credentials.projectSecret),
new PhotonState({ companyId: options.companyId, endpointId: options.endpointId }, options.persistence))
: createProviderAdapter(
options.providerConfig,
options.logger,
options.callbacks,
options.endpointId,
(fatal) => {
this.discordGatewayFatal = fatal;
},
);
if (this.provider === "telegram") {
const adapter = this.adapter as unknown as {
botUserId?: string;
processUpdate(update: unknown, options?: WebhookOptions): void;
};
if (typeof adapter.processUpdate !== "function") {
throw new Error(
"Telegram authenticated dispatch contract is unavailable",
);
}
const processUpdate = adapter.processUpdate.bind(this.adapter);
adapter.processUpdate = (update, webhookOptions) => {
// The pinned webhook verifier calls processUpdate only after checking
// the secret. No parser-normalized chat:0 input may enter ordinary work.
if (hasTelegramEphemeralInput(update)) return;
const attempt = this.webhookIngress.getStore();
if (isRecord(update) && "stopped_message_generation" in update) {
// This wrapper is reached only after the pinned webhook secret check.
// Join the durable callback to the same HTTP acknowledgement barrier.
const proof =
attempt && adapter.botUserId
? captureTelegramGenerationStopped(adapter.botUserId, update)
: null;
if (proof && options.callbacks.onTelegramGenerationStopped) {
const task = Promise.resolve().then(() =>
options.callbacks.onTelegramGenerationStopped!({
provider: "telegram",
endpointId: this.endpointId,
event: proof,
}),
);
attempt!.callbackPromises.add(task);
const handled = task
.catch((error) => {
if (attempt!.callbackError === undefined)
attempt!.callbackError = error;
})
.finally(() => attempt!.callbackPromises.delete(task));
webhookOptions?.waitUntil?.(handled);
}
return;
}
if (attempt && adapter.botUserId) {
const captured = captureTelegramCallbackProvenance(
{
companyId: this.companyId,
endpointId: this.endpointId,
botUserId: adapter.botUserId,
},
update,
attempt.receivedAtMs,
);
if (captured)
this.telegramCallbackProofs.set(captured.raw, captured.proof);
}
processUpdate(update, webhookOptions);
};
}
const durableState = createPaperclipChatSdkState({
companyId: options.companyId,
endpointId: options.endpointId,
persistence: options.persistence,
maxValueBytes: options.maxStateValueBytes,
});
const state = paperclipAuthoritativeIngressState(durableState, (error) => {
const attempt = this.webhookIngress.getStore();
if (attempt && attempt.callbackError === undefined) {
attempt.callbackError = error;
}
});
this.chat = new Chat({
adapters: { [this.sdkAdapterKey]: this.adapter },
// Paperclip acknowledges only after its own durable ledger write and
// drains that ledger under database leases. An SDK-side queue can accept
// a webhook before the application callback has run, so it must not sit
// in front of the authoritative receipt boundary.
concurrency: "concurrent",
logger: options.logger,
state,
userName: options.providerConfig.userName,
});
if (this.provider === "discord") {
assertDiscordAdapterCompatibility(this.adapter, this.chat);
}
registerCallbacks(
this.chat,
this.endpointId,
this.provider,
options.callbacks,
async <T>(callback: () => Promise<T> | T): Promise<T> => {
const attempt = this.webhookIngress.getStore();
const promise = Promise.resolve().then(callback);
if (!attempt) return await promise;
attempt.callbackPromises.add(promise);
try {
return await promise;
} catch (error) {
if (attempt.callbackError === undefined)
attempt.callbackError = error;
throw error;
} finally {
attempt.callbackPromises.delete(promise);
}
},
(raw) => this.acceptsProviderScope(raw),
() => this.webhookIngress.getStore()?.providerUpdateId,
() =>
this.provider === "discord" && !this.webhookIngress.getStore()
? "discord_gateway"
: undefined,
() => this.discordCommandDispatch.getStore(),
(raw) => isRecord(raw) ? this.telegramCallbackProofs.get(raw) : undefined,
);
if (
options.providerConfig.provider === "discord" &&
options.callbacks.onSlashCommand
) {
installDiscordNativeCommands(
this.adapter,
this.chat,
options.providerConfig.credentials.applicationId,
options.providerConfig.credentials.guildId,
this.discordCommandDispatch,
);
}
if (this.teamsFileConsentEnabled) {
const app = (this.adapter as unknown as TeamsAdapterInternals).app;
if (
!app ||
typeof app.on !== "function" ||
!this.microsoftTeamsTenantId ||
options.providerConfig.provider !== "microsoft-teams"
) {
throw new TeamsAdapterCompatibilityError(
"file-consent requires a configured tenant and App hook",
);
}
const callback = options.callbacks.onTeamsFileConsent!;
installTeamsFileConsentHook(app as TeamsConsentApp, {
companyId: this.companyId,
endpointId: this.endpointId,
tenantId: this.microsoftTeamsTenantId,
botAppId: options.providerConfig.credentials.appId,
onConsent: async (event) => {
const attempt = this.webhookIngress.getStore();
const promise = Promise.resolve().then(() =>
callback({
endpointId: this.endpointId,
provider: this.provider,
event,
}),
);
if (!attempt) return await promise;
attempt.callbackPromises.add(promise);
try {
return await promise;
} catch (error) {
if (attempt.callbackError === undefined)
attempt.callbackError = error;
throw error;
} finally {
attempt.callbackPromises.delete(promise);
}
},
});
}
}
async initialize(): Promise<void> {
await this.initializeChat();
this.assertNotRetired();
if (this.adapter instanceof PhotonChatAdapter && this.discordGatewayEnabled && !this.photonReceiver) {
const options = this.runtimeOptions;
const config = options.providerConfig as ResolvedPhotonChatConfig;
if (!options.callbacks.onPhotonCheckpoint || !options.callbacks.onPhotonAssertOwned || !options.callbacks.onPhotonEvent || !options.callbacks.onPhotonFailure) throw new Error("Photon receiver requires durable admission callbacks");
this.adapter.typingGuard = async (activeThreadId) => { this.assertNotRetired(); await options.callbacks.onPhotonAssertOwned!(activeThreadId); };
this.photonReceiver = new PhotonReceiver({ client: this.adapter.client, state: this.adapter.state,
lineId: config.credentials.lineId, intakeAfter: config.intakeAfter, allocation: config.credentials.allocation,
catchUp: (sequence) => (this.adapter as PhotonChatAdapter).recoveryStream(sequence),
assertOwned: async () => { this.assertNotRetired(); await (this.adapter as PhotonChatAdapter).authentication.token(); await options.callbacks.onPhotonAssertOwned!(); },
commitCheckpoint: options.callbacks.onPhotonCheckpoint, admit: options.callbacks.onPhotonEvent, failure: options.callbacks.onPhotonFailure });
this.photonReceiver.start();
}
if (this.provider === "discord" && this.discordGatewayEnabled) {
this.startDiscordGateway();
}
}
private async initializeChat(): Promise<void> {
this.assertNotRetired();
// The service still chooses when to initialize, after installing callback
// context. Retirement owns the settlement of that exact SDK operation.
this.initialization ??= this.chat.initialize();
await this.initialization;
this.assertNotRetired();
}
private assertNotRetired(): void {
if (this.retired) throw new Error("Chat SDK endpoint runtime was retired");
}
private startDiscordGateway(): void {
this.assertNotRetired();
if (this.discordGatewayTask) return;
const adapter = this.adapter as DiscordAdapter;
if (typeof adapter.startGatewayListener !== "function") return;
const abort = new AbortController();
this.discordGatewayAbort = abort;
this.discordGatewayTask = (async () => {
let rapidRestartCount = 0;
while (!abort.signal.aborted) {
const sessionStartedAt = Date.now();
let listener: Promise<unknown> | null = null;
try {
await adapter.startGatewayListener(
{
waitUntil: (task) => {
listener = Promise.resolve(task);
},
},
DISCORD_GATEWAY_SESSION_MS,
abort.signal,
);
if (listener) await listener;
} catch {
// The adapter logs provider-specific failures. Keep the supervisor
// alive so a transient start failure cannot become an unhandled
// rejection that tears down the server.
}
if (abort.signal.aborted || this.discordGatewayFatal) break;
rapidRestartCount =
Date.now() - sessionStartedAt >= DISCORD_GATEWAY_HEALTHY_SESSION_MS
? 0
: Math.min(rapidRestartCount + 1, 16);
const restartDelayMs = Math.min(
1_000 * 2 ** Math.max(0, rapidRestartCount - 1),
DISCORD_GATEWAY_RESTART_MAX_DELAY_MS,
);
await new Promise<void>((resolve) => {
let settled = false;
const finish = () => {
if (settled) return;
settled = true;
clearTimeout(timer);
abort.signal.removeEventListener("abort", finish);
resolve();
};
const timer = setTimeout(finish, restartDelayMs);
timer.unref?.();
if (abort.signal.aborted) finish();
else abort.signal.addEventListener("abort", finish, { once: true });
});
}
})().finally(() => {
this.discordGatewayTask = null;
});
}
async handleWebhook(
request: Request,
options?: WebhookOptions,
responseDeadlineAt?: number,
serviceReceivedAtMs?: number,
): Promise<Response> {
this.assertNotRetired();
// A body read, identity initialization or durable queue wait cannot mint
// a fresh provider response window for an already received callback.
const runtimeReceivedAtMs = Date.now();
const receivedAtMs =
typeof serviceReceivedAtMs === "number" &&
Number.isSafeInteger(serviceReceivedAtMs) &&
serviceReceivedAtMs > 0
? Math.min(serviceReceivedAtMs, runtimeReceivedAtMs)
: runtimeReceivedAtMs;
const handler = this.chat.webhooks[this.sdkAdapterKey];
if (!handler) {
throw new Error(
`Chat SDK webhook handler is unavailable for ${this.provider}`,
);
}
const providerUpdateId =
this.provider === "telegram"
? await telegramWebhookUpdateId(request)
: undefined;
const attempt: WebhookIngressAttempt = {
receivedAtMs,
callbackError: undefined,
callbackPromises: new Set(),
...(providerUpdateId !== undefined ? { providerUpdateId } : {}),
};
const sdkTasks: Promise<unknown>[] = [];
const deadlineAt = Math.min(
Date.now() + this.webhookIngressTimeoutMs,
responseDeadlineAt ?? Number.POSITIVE_INFINITY,
);
const retryableTimeout = () =>
new Response("Paperclip could not durably accept the event in time", {
status: 503,
headers: {
"content-type": "text/plain; charset=utf-8",
"retry-after": "1",
},
});
const beforeDeadline = async <T>(
task: Promise<T>,
): Promise<
{ completed: true; value: T } | { completed: false; value?: never }
> => {
const remaining = deadlineAt - Date.now();
if (remaining <= 0) return { completed: false };
let timer: ReturnType<typeof setTimeout> | null = null;
const timedOut = new Promise<{ completed: false }>((resolve) => {
timer = setTimeout(() => resolve({ completed: false }), remaining);
timer.unref?.();
});
const result = await Promise.race([
task.then((value) => ({ completed: true as const, value })),
timedOut,
]);
if (timer) clearTimeout(timer);
return result;
};
return await this.webhookIngress.run(attempt, async () => {
// The SDK also auto-initializes through its webhook entry. Keep that
// operation inside the ingress deadline and the same retirement fence,
// without starting the caller-owned Discord Gateway here.
if (!(await beforeDeadline(this.initializeChat())).completed)
return retryableTimeout();
this.assertNotRetired();
const handlerResult = await beforeDeadline(
handler(request, {
...options,
waitUntil: (task) => {
sdkTasks.push(task);
options?.waitUntil?.(task);
},
}),
);
if (!handlerResult.completed) return retryableTimeout();
const response = handlerResult.value;
// Adapters return their provider acknowledgement immediately and put
// normalized message dispatch behind waitUntil. Production callbacks do
// only the delivery-ledger insert here, so this wait preserves Slack's
// response budget while guaranteeing no 2xx precedes durable receipt.
if (!(await beforeDeadline(Promise.allSettled(sdkTasks))).completed)
return retryableTimeout();
while (attempt.callbackPromises.size > 0) {
if (
!(
await beforeDeadline(
Promise.allSettled([...attempt.callbackPromises]),
)
).completed
)
return retryableTimeout();
}
if (response.ok && attempt.callbackError !== undefined) {
return new Response("Paperclip could not durably accept the event", {
status: 503,
headers: {
"content-type": "text/plain; charset=utf-8",
"retry-after": "1",
},
});
}
return response;
});
}
thread(threadId: string): Thread {
return this.chat.thread(threadId);
}
async streamTelegramDraft(
threadId: string,
textStream: AsyncIterable<string>,
control: TelegramDraftControl,
): Promise<{ id: string } | TelegramDraftStopped> {
const adapter = this.adapter as unknown as {
paperclipDraftStopVersion?: number;
stream(
threadId: string,
stream: AsyncIterable<string>,
options: unknown,
): Promise<{ id: string } | TelegramDraftStopped>;
};
if (
this.provider !== "telegram" ||
!telegramPrivateDraftDestination(threadId) ||
adapter.paperclipDraftStopVersion !== 1
) {
throw new Error("Telegram durable draft transport is unavailable");
}
return adapter.stream(threadId, textStream, {
paperclipDraftControl: control,
});
}
/**
* Post one Slack file-only publication while durably recording the accepted
* upload IDs before the adapter performs its eventually-consistent share
* lookup. This specialized receipt scope is deliberately unavailable for
* cards, edits, and ordinary text sends; the send still uses Thread.post.
*/
async postSlackFilePublication(
threadId: string,
message: Parameters<Thread["post"]>[0],
onUploadAccepted: (
receipt: SlackFileUploadAcceptedReceipt,
) => Promise<void>,
): Promise<{ id: string }> {
if (this.provider !== "slack") {
throw new SlackAdapterCompatibilityError(
"file publication receipt capture was called for a non-Slack endpoint",
);
}
const slack = this.adapter as unknown as SlackAdapterInternals;
if (
!slack.paperclipFileUploadReceiptContext ||
typeof slack.paperclipResolveFileUploadReceipt !== "function"
) {
throw new SlackAdapterCompatibilityError(
"file publication receipt capture is unavailable",
);
}
return await slack.paperclipFileUploadReceiptContext.run(
onUploadAccepted,
async () => await this.chat.thread(threadId).post(message),
);
}
/** Resolve a previously accepted Slack upload using metadata reads only. */
async resolveSlackFileUploadReceipt(
threadId: string,
fileIds: string[],
): Promise<string | null> {
if (this.provider !== "slack") {
throw new SlackAdapterCompatibilityError(
"file publication receipt lookup was called for a non-Slack endpoint",
);
}
const slack = this.adapter as unknown as SlackAdapterInternals;
if (typeof slack.paperclipResolveFileUploadReceipt !== "function") {
throw new SlackAdapterCompatibilityError(
"file publication receipt lookup is unavailable",
);
}
return await slack.paperclipResolveFileUploadReceipt.call(
this.adapter,
fileIds,
threadId,
);
}
/**
* Persist a Teams activity's authenticated reply route only after the
* control plane has accepted the callback under the current runtime and
* credential generation. The route is intentionally separate from the
* durable provider thread id because Microsoft can move a conversation
* between regional Bot Connector service URLs.
*/
async recordMicrosoftTeamsRoute(
threadId: string,
serviceUrl: unknown,
raw?: unknown,
): Promise<void> {
if (this.provider !== "microsoft-teams") return;
const teams = this.adapter as unknown as TeamsAdapterInternals;
const recorder = teams.paperclipRecordThreadServiceUrl;
if (typeof recorder !== "function") {
throw new TeamsAdapterCompatibilityError(
"durable route recorder is unavailable",
);
}
await recorder.call(this.adapter, threadId, serviceUrl);
if (raw !== undefined) {
const acceptedActivityRecorder = teams.paperclipRecordAcceptedActivity;
if (typeof acceptedActivityRecorder !== "function") {
throw new TeamsAdapterCompatibilityError(
"durable accepted-activity recorder is unavailable",
);
}
await acceptedActivityRecorder.call(this.adapter, raw);
}
}
async sendTeamsFileConsentCard(
threadId: string,
card: ReturnType<typeof buildTeamsFileConsentCard>,
): Promise<{ id: string }> {
return await this.sendTeamsFileCard(threadId, "consent", card);
}
async sendTeamsUploadedFileCard(
threadId: string,
card: ReturnType<typeof buildTeamsUploadedFileCard>,
): Promise<{ id: string }> {
return await this.sendTeamsFileCard(threadId, "file_info", card);
}
private async sendTeamsFileCard(
threadId: string,
kind: "consent" | "file_info",
card: unknown,
): Promise<{ id: string }> {
const teams = this.adapter as unknown as TeamsAdapterInternals;
if (
!this.teamsFileConsentEnabled ||
typeof teams.paperclipSendFileCard !== "function"
) {
throw new TeamsAdapterCompatibilityError(
"file-consent runtime is not enabled",
);
}
return await teams.paperclipSendFileCard(threadId, kind, card);
}
channel(channelId: string): Channel {
return this.chat.channel(channelId);
}
async openDirectMessage(user: string | Author): Promise<Thread> {
return await this.chat.openDM(user);
}
async getUser(user: string | Author): Promise<UserInfo | null> {
return await this.chat.getUser(user);
}
async abortTurn(threadId: string): Promise<void> {
await this.chat.abortTurn(threadId);
}
getProviderAdapter(): Adapter {
return this.adapter;
}
async ensureDiscordRootThread(input: {
channelId: string;
content: string;
messageId: string;
}): Promise<void> {
if (this.provider !== "discord") {
throw new DiscordAdapterCompatibilityError(
"ensureDiscordRootThread was called for a non-Discord endpoint",
);
}
const discord = this.adapter as unknown as DiscordAdapterInternals & {
ensureRootThread?: (
channelId: string,
messageId: string,
content: string,
) => Promise<unknown>;
};
if (typeof discord.ensureRootThread !== "function") {
throw new DiscordAdapterCompatibilityError(
"ensureRootThread is unavailable",
);
}
await discord.ensureRootThread.call(
this.adapter,
input.channelId,
input.messageId,
input.content,
);
}
/**
* Reuse the pinned provider parser when a Telegram slash command also
* carries media in its caption. The Chat SDK exposes slash-command fields
* separately from the parsed Message, so without this bridge Paperclip
* would silently discard a captioned document/photo/audio/video.
*/
parseTelegramCommandMessage(raw: unknown): Message | null {
if (this.provider !== "telegram" || !raw || typeof raw !== "object")
return null;
return (this.adapter as TelegramAdapter).parseMessage(
raw as TelegramRawMessage,
);
}
/**
* Reuse the pinned Teams parser for verified messageUpdate/messageDelete
* activities. The adapter exposes a public parser but does not currently
* dispatch those activities through Chat's lifecycle callbacks.
*/
parseMicrosoftTeamsMessage(raw: unknown): Message | null {
if (this.provider !== "microsoft-teams" || !raw || typeof raw !== "object")
return null;
return (this.adapter as TeamsAdapter).parseMessage(raw);
}
/**
* Keep a dedicated Teams endpoint bound to the configured organization.
* Bot Framework authentication validates the service token, app audience,
* and service URL, but its service-issued JWT is not tenant-scoped. The
* verified activity body therefore remains the authoritative tenant claim.
*/
acceptsProviderScope(raw: unknown): boolean {
if (this.provider === "discord" && this.discordGuildId) {
if (!isRecord(raw)) return false;
const guildId = raw.guild_id;
return (
guildId === this.discordGuildId || guildId === null || guildId === "@me"
);
}
if (this.provider !== "microsoft-teams") return true;
if (
isRecord(raw) &&
isRecord(raw.recipient) &&
raw.recipient.isTargeted === true
) {
return false;
}
if (!this.microsoftTeamsTenantId) return true;
const tenantIds = microsoftTeamsTenantIds(raw);
return (
tenantIds.length > 0 &&
tenantIds.every((tenantId) => tenantId === this.microsoftTeamsTenantId)
);
}
/** Build the closed, credential-free locator stored with durable ingress. */
attachmentRecoveryDescriptor(
attachment: Attachment,
source?: ChatSdkAttachmentSource,
): ChatSdkAttachmentRecoveryDescriptor | null {
if (this.provider === "telegram" && hasTelegramMediaProvenance(attachment)) {
const scope = this.telegramMediaScope(source);
const locator = scope && telegramMediaLocator(attachment, scope);
const metadata = durableAttachmentMetadata(attachment);
return locator && metadata ? { version: 1, provider: "telegram", attachment: metadata, locator } : null;
}
if (this.adapter instanceof PhotonChatAdapter && source?.message) {
const thread = this.adapter.decodeThreadId(source.threadId);
const locator = photonAttachmentLocator(attachment, thread.lineId, thread.chatGuid, source.message);
const metadata = durableAttachmentMetadata(attachment);
return locator && metadata ? { version: 1, provider: "imessage-photon", attachment: metadata, locator } : null;
}
const retained = this.teamsInlineImageDescriptors.get(attachment);
if (retained) {
if (!source) return retained;
const scope = this.teamsInlineImageScope(source);
return scope && parseTeamsInlineImageLocator(retained.locator, scope)
? retained
: null;
}
if (
this.provider === "microsoft-teams" &&
source?.isDirectMessage === false
) {
const scope = this.teamsInlineImageScope(source);
const locator =
scope && source.message
? deriveTeamsInlineImageLocator(source.message, attachment, scope)
: null;
const metadata = durableAttachmentMetadata(attachment);
return locator && metadata
? { version: 1, provider: this.provider, attachment: metadata, locator }
: null;
}
return createAttachmentRecoveryDescriptor(this.provider, attachment);
}
private telegramMediaScope(source?: ChatSdkAttachmentSource): TelegramMediaScope | null {
if (!source || source.runtimeGeneration === undefined || !source.credentialFingerprint || !source.principalExternalId) return null;
return { companyId: this.companyId, endpointId: this.endpointId, runtimeGeneration: source.runtimeGeneration,
credentialFingerprint: source.credentialFingerprint, threadId: source.threadId, messageId: source.messageId,
principalExternalId: source.principalExternalId };
}
private teamsInlineImageScope(
source?: ChatSdkAttachmentSource,
): TeamsInlineImageScope | null {
if (
!source ||
!this.microsoftTeamsTenantId ||
!this.microsoftTeamsAppId ||
source.runtimeGeneration === undefined ||
!source.credentialFingerprint ||
!source.principalExternalId
)
return null;
return {
companyId: this.companyId,
endpointId: this.endpointId,
tenantId: this.microsoftTeamsTenantId,
botAppId: this.microsoftTeamsAppId,
runtimeGeneration: source.runtimeGeneration,
credentialFingerprint: source.credentialFingerprint,
threadId: source.threadId,
messageId: source.messageId,
principalExternalId: source.principalExternalId,
};
}
/** Service-owned receipt action; no ordinary message or native status retry. */
async applyGitHubReceiptReaction(
input: GitHubReceiptMutation,
assertCurrent: () => Promise<void>,
fetchImpl?: typeof globalThis.fetch,
) {
if (this.provider !== "github" || !this.githubReceiptApp)
throw new Error("GitHub receipt runtime unavailable");
return applyGitHubReceiptReaction(
this.adapter,
this.githubReceiptApp.appId,
this.githubReceiptApp.installationId,
input,
assertCurrent,
fetchImpl,
);
}
async applySlackReceiptReaction(
input: SlackReceiptMutation,
fetchImpl?: typeof globalThis.fetch,
): Promise<void> {
if (this.provider !== "slack" || !this.slackReceiptBotToken)
throw new Error("Slack receipt runtime unavailable");
await applySlackReceiptReaction(
{ ...input, botToken: this.slackReceiptBotToken },
fetchImpl,
);
}
async sendTelegramCallbackNotice(
receipt: TelegramCallbackReceipt,
text: string,
fetchImpl?: typeof globalThis.fetch,
): Promise<{ id: string; threadId: string }> {
if (
this.provider !== "telegram" ||
!this.telegramEphemeralBotToken ||
receipt.companyId !== this.companyId ||
receipt.endpointId !== this.endpointId
) {
throw Object.assign(
new Error("Telegram private response runtime unavailable"),
{ code: "CHAT_PROVIDER_PRETRANSPORT_REJECTED" },
);
}
return sendTelegramCallbackNotice(
{ receipt, text, botToken: this.telegramEphemeralBotToken },
fetchImpl,
);
}
/** Only attachments reconstructed by this runtime can use the batch budget. */
async fetchTeamsInlineImage(
attachment: Attachment,
signal: AbortSignal,
): Promise<Buffer> {
const fetcher = this.teamsInlineImageFetchers.get(attachment);
if (!fetcher || signal.aborted)
throw new Error("Teams inline image download unavailable");
return await fetcher(signal);
}
/** Called only after current inbound admission; installation App authority only. */
async resolveGitHubAttachmentComment(
request: GitHubAttachmentCommentRequest,
signal: AbortSignal,
): Promise<unknown> {
if (!this.githubAttachmentAppAuthority)
throw new GitHubAttachmentUnavailableError(
"github_attachment_canonical_authority_unavailable",
);
if (!isGitHubAttachmentCommentRequest(request))
throw new GitHubAttachmentUnavailableError(
"github_attachment_source_mismatch",
);
try {
signal.throwIfAborted();
const result = await (this.adapter as GitHubAdapter).octokit.request(
`GET ${request.url}`,
{
headers: {
accept: request.accept,
"x-github-api-version": "2022-11-28",
},
request: {
signal,
redirect: "manual",
fetch: githubAttachmentCommentFetch(request, signal),
},
},
);
signal.throwIfAborted();
return result.data;
} catch (error) {
// Octokit errors can carry request headers or authenticated HTML. Neither
// belongs in adapter logs, durable ingress, nor agent-visible results.
throw new GitHubAttachmentUnavailableError(
githubAttachmentDiagnosticCode(error) ??
"github_attachment_canonical_api_request_failed",
);
}
}
/**
* Rebuild an adapter-authenticated download closure after process restart.
* Invalid, cross-provider, or no-longer-safe descriptors fail closed.
*/
rehydrateAttachment(
descriptor: unknown,
source?: ChatSdkAttachmentSource,
): Attachment | null {
if (this.adapter instanceof PhotonChatAdapter && isRecord(descriptor) && descriptor.version === 1 && descriptor.provider === this.provider && source) {
const parsed = photonAttachmentLocatorSchema.safeParse(descriptor.locator);
const thread = this.adapter.decodeThreadId(source.threadId);
const metadata = isRecord(descriptor.attachment) ? durableAttachmentMetadata(descriptor.attachment as unknown as Attachment) : null;
if (!parsed.success || !metadata || parsed.data.lineId !== thread.lineId || parsed.data.chatGuid !== thread.chatGuid || parsed.data.messageGuid !== source.messageId) return null;
const adapter = this.adapter;
return { ...metadata, fetchData: () => downloadPhotonAttachment(adapter.client, thread.lineId, parsed.data, adapter.authentication.identity.allocation) };
}
if (
this.provider === "microsoft-teams" &&
isRecord(descriptor) &&
isRecord(descriptor.locator) &&
descriptor.locator.kind === "teams_inline_image"
) {
const scope = this.teamsInlineImageScope(source);
const locator = scope
? parseTeamsInlineImageLocator(descriptor.locator, scope)
: null;
const metadata = isRecord(descriptor.attachment)
? durableAttachmentMetadata(
descriptor.attachment as unknown as Attachment,
)
: null;
if (
!locator ||
!metadata ||
source?.isDirectMessage !== false ||
descriptor.version !== 1 ||
descriptor.provider !== this.provider ||
Object.keys(descriptor).sort().join(",") !==
"attachment,locator,provider,version" ||
Object.keys(descriptor.attachment as object).some(
(key) =>
!["type", "mimeType", "name", "size", "height", "width"].includes(
key,
),
) ||
metadata.type !== "image" ||
metadata.mimeType !== locator.mimeType ||
(metadata.size !== undefined && metadata.size > MAX_ATTACHMENT_BYTES)
)
return null;
const normalized: ChatSdkAttachmentRecoveryDescriptor = {
version: 1,
provider: this.provider,
attachment: metadata,
locator,
};
const fetchData = async (batchSignal?: AbortSignal): Promise<Buffer> => {
const controller = new AbortController();
const signal = batchSignal
? AbortSignal.any([batchSignal, controller.signal])
: controller.signal;
let rejectDeadline!: () => void;
const deadline = new Promise<never>((_resolve, reject) => {
rejectDeadline = () =>
reject(new Error("Teams inline image download unavailable"));
});
signal.addEventListener("abort", rejectDeadline, { once: true });
const timer = setTimeout(() => controller.abort(), 10_000);
try {
// Same authenticated SDK HTTP client as the pinned parser, with an
// explicit allocation/deadline bound and no redirects. Never use
// a caller-supplied fetchData closure or bearer URL.
const http = (
this.adapter as unknown as {
app: {
api: {
http: {
get(
url: string,
options: unknown,
): Promise<{ data: ArrayBuffer | Uint8Array }>;
};
};
};
}
).app.api.http;
signal.throwIfAborted();
const result = await Promise.race([
http.get(teamsInlineImageDownloadUrl(locator), {
responseType: "arraybuffer",
maxRedirects: 0,
maxContentLength: MAX_ATTACHMENT_BYTES,
maxBodyLength: MAX_ATTACHMENT_BYTES,
timeout: 10_000,
signal,
}),
deadline,
]);
signal.throwIfAborted();
if (
!result.data ||
(!(result.data instanceof ArrayBuffer) &&
!ArrayBuffer.isView(result.data)) ||
result.data.byteLength > MAX_ATTACHMENT_BYTES
)
throw new Error("Image exceeds attachment bound");
return Buffer.from(
result.data instanceof ArrayBuffer
? new Uint8Array(result.data)
: result.data,
);
} catch {
throw new Error("Teams inline image download unavailable");
} finally {
clearTimeout(timer);
signal.removeEventListener("abort", rejectDeadline);
}
};
const attachment: Attachment = { ...metadata, fetchData: () => fetchData() };
this.teamsInlineImageDescriptors.set(attachment, normalized);
this.teamsInlineImageFetchers.set(attachment, fetchData);
return attachment;
}
if (
this.provider === "telegram" &&
isRecord(descriptor) &&
descriptor.version === 1 &&
descriptor.provider === "telegram" &&
isRecord(descriptor.locator) &&
descriptor.locator.kind === "telegram_media"
) {
const scope = this.telegramMediaScope(source);
const metadata =
isRecord(descriptor.attachment) &&
durableAttachmentMetadata(
descriptor.attachment as unknown as Attachment,
);
const locator =
scope &&
metadata &&
validateTelegramMediaLocator(descriptor.locator, metadata, scope);
if (!locator || !metadata || !this.adapter.rehydrateAttachment)
return null;
const attachment = this.adapter.rehydrateAttachment({
...metadata,
fetchMetadata: {
fileId: locator.fileId,
fileUniqueId: locator.fileUniqueId,
},
});
return attachment
? retainTelegramMediaProvenance(attachment, locator)
: null;
}
const validated = validatedAttachmentRecoveryDescriptor(
this.provider,
descriptor,
);
if (!validated) return null;
if (validated.locator.kind === "github_public_attachment") {
return source
? rehydrateGitHubPublicAttachment(validated.locator, source)
: null;
}
if (!this.adapter.rehydrateAttachment) return null;
let fetchMetadata: Record<string, string>;
switch (validated.locator.kind) {
case "slack_private_url":
fetchMetadata = {
url: validated.locator.url,
...(validated.locator.teamId
? { teamId: validated.locator.teamId }
: {}),
...(validated.locator.enterpriseId
? { enterpriseId: validated.locator.enterpriseId }
: {}),
...(validated.locator.isEnterpriseInstall
? { isEnterpriseInstall: "true" }
: {}),
};
break;
case "teams_bot_url":
fetchMetadata = {
url: validated.locator.url,
auth: "bot",
connectorOrigin: validated.locator.connectorOrigin,
};
break;
case "photon_attachment":
case "teams_inline_image":
return null; // Only the exact source-bound branch above may authorize it.
case "telegram_media":
return null; // Only the exact source-bound branch above may authorize it.
case "teams_anonymous_url":
fetchMetadata = { url: validated.locator.url };
break;
case "discord_cdn_url":
fetchMetadata = { url: validated.locator.url };
break;
case "telegram_file_id":
fetchMetadata = {
fileId: validated.locator.fileId,
...(validated.locator.fileUniqueId
? { fileUniqueId: validated.locator.fileUniqueId }
: {}),
};
break;
}
return this.adapter.rehydrateAttachment({
...validated.attachment,
fetchMetadata,
});
}
async shutdown(): Promise<void> {
// Set synchronously: an already-resolving initialize must not start a
// Gateway after retirement, and this runtime can never be reinitialized.
this.retired = true;
if (this.shutdownCompleted) return;
if (this.shutdownTask) return await this.shutdownTask;
this.discordGatewayAbort?.abort();
const task = (async () => {
// Even a rejected initialization may have connected state or partially
// initialized an adapter. Join it before disconnecting that ownership.
await this.initialization?.catch(() => undefined);
await this.discordGatewayTask?.catch(() => undefined);
this.discordGatewayAbort = null;
await this.photonReceiver?.close();
await this.chat.shutdown();
this.shutdownCompleted = true;
})();
this.shutdownTask = task;
try {
await task;
} finally {
// A failed disconnect retains a permanently retired owner, allowing the
// registry to retry shutdown without permitting another initialization.
if (this.shutdownTask === task) this.shutdownTask = null;
}
}
}
export function createChatSdkEndpointRuntime(
options: CreateChatSdkEndpointRuntimeOptions,
): ChatSdkEndpointRuntime {
return new ChatSdkEndpointRuntime(options);
}
export class ChatSdkEndpointNotRegisteredError extends Error {
readonly endpointId: string;
constructor(endpointId: string) {
super(`No Chat SDK runtime is registered for endpoint ${endpointId}`);
this.name = "ChatSdkEndpointNotRegisteredError";
this.endpointId = endpointId;
}
}
/** Process-local lifecycle registry; durable state remains in Paperclip persistence. */
export class ChatSdkRuntime {
private readonly endpoints = new Map<string, ChatSdkEndpointRuntime>();
private readonly retiringEndpoints = new Map<
string,
ChatSdkEndpointRuntime
>();
private readonly lifecycleTails = new Map<string, Promise<void>>();
private readonly replacementGenerations = new Map<string, number>();
private shuttingDown = false;
get(endpointId: string): ChatSdkEndpointRuntime | null {
if (this.shuttingDown) return null;
return this.endpoints.get(endpointId) ?? null;
}
list(): ChatSdkEndpointRuntime[] {
if (this.shuttingDown) return [];
return [...this.endpoints.values()];
}
/** Retirement failures retain their owner even after get/list hide it. */
hasUnsettledLifecycle(): boolean {
return this.retiringEndpoints.size > 0 || this.lifecycleTails.size > 0;
}
private enqueueLifecycle<T>(
endpointId: string,
operation: () => Promise<T>,
): Promise<T> {
const previous = this.lifecycleTails.get(endpointId) ?? Promise.resolve();
const result = previous.then(operation);
// A failed shutdown must not poison the queue or release ownership of a
// possibly still-running predecessor. Only an explicit later operation
// retries retirement; callers still receive the original failure.
const settled = result.then(
() => undefined,
() => undefined,
);
this.lifecycleTails.set(endpointId, settled);
void settled.then(() => {
if (this.lifecycleTails.get(endpointId) === settled) {
this.lifecycleTails.delete(endpointId);
}
});
return result;
}
private async retireEndpoint(endpointId: string): Promise<boolean> {
const previous =
this.endpoints.get(endpointId) ?? this.retiringEndpoints.get(endpointId);
if (!previous) return false;
this.endpoints.delete(endpointId);
this.retiringEndpoints.set(endpointId, previous);
await previous.shutdown();
this.retiringEndpoints.delete(endpointId);
return true;
}
private nextReplacementGeneration(endpointId: string): number {
const next = (this.replacementGenerations.get(endpointId) ?? 0) + 1;
this.replacementGenerations.set(endpointId, next);
return next;
}
private assertReplacementCurrent(
endpointId: string,
generation: number,
): void {
if (this.shuttingDown) throw new Error("Chat SDK runtime is shutting down");
if (this.replacementGenerations.get(endpointId) !== generation) {
throw new Error(
`Chat SDK runtime replacement for endpoint ${endpointId} was superseded`,
);
}
}
async replaceEndpoint(
options: CreateChatSdkEndpointRuntimeOptions,
): Promise<ChatSdkEndpointRuntime> {
if (this.shuttingDown) throw new Error("Chat SDK runtime is shutting down");
const generation = this.nextReplacementGeneration(options.endpointId);
return await this.enqueueLifecycle(options.endpointId, async () => {
this.assertReplacementCurrent(options.endpointId, generation);
await this.retireEndpoint(options.endpointId);
this.assertReplacementCurrent(options.endpointId, generation);
const next = createChatSdkEndpointRuntime(options);
this.endpoints.set(options.endpointId, next);
// The service installs callback context before initialize() starts the
// Discord gateway. Preserve that caller-owned initialization boundary.
return next;
});
}
async removeEndpoint(endpointId: string): Promise<boolean> {
this.nextReplacementGeneration(endpointId);
return await this.enqueueLifecycle(
endpointId,
async () => await this.retireEndpoint(endpointId),
);
}
async handleWebhook(
endpointId: string,
request: Request,
options?: WebhookOptions,
responseDeadlineAt?: number,
): Promise<Response> {
const runtime = this.get(endpointId);
if (!runtime) throw new ChatSdkEndpointNotRegisteredError(endpointId);
return await runtime.handleWebhook(request, options, responseDeadlineAt);
}
async shutdown(): Promise<void> {
this.shuttingDown = true;
const endpointIds = new Set([
...this.endpoints.keys(),
...this.retiringEndpoints.keys(),
...this.lifecycleTails.keys(),
]);
const results = await Promise.allSettled(
[...endpointIds].map(
async (endpointId) =>
await this.enqueueLifecycle(
endpointId,
async () => await this.retireEndpoint(endpointId),
),
),
);
const failure = results.find((result) => result.status === "rejected");
if (failure?.status === "rejected") throw failure.reason;
}
}
export function createChatSdkRuntime(): ChatSdkRuntime {
return new ChatSdkRuntime();
}