import { AsyncLocalStorage } from "node:async_hooks"; import { createMemoryState } from "@chat-adapter/state-memory"; import type { AdapterContext, AdapterDescriptor, MessagingCapabilities, MessagingInboundEvent, MessagingInboundMessage, MessagingOutboundStatus, MessagingPlatformDescriptor, MessagingSendRequest, MessagingSendResult, MessagingSurface, } from "@rakazo/adapter-kit"; import type { Adapter, Message, Thread } from "chat"; import { Chat } from "chat"; /** Per-webhook drain of Chat SDK waitUntil work + inbound sink failures. */ const webhookDrain = new AsyncLocalStorage<{ pending: Promise[]; failed: boolean; }>(); /** Inbound webhook bodies larger than this are rejected before parsing. */ export const MESSAGING_WEBHOOK_MAX_BODY_BYTES = 256 * 1024; /** * One messaging platform mounted on the surface. The Chat SDK adapter owns * webhook verification, payload translation, and platform API calls; the * extra hooks cover the few per-platform facts the SDK does not surface. */ export interface MessagingPlatform { provider: string; capabilities: MessagingCapabilities; adapter: Adapter; /** Deterministic 1:1 thread id for platforms without openDM (sendblue). */ directThreadId?: (address: string) => string; /** Delivery-status events the Chat SDK drops (sendblue is_outbound posts). */ peekStatus?: (payload: unknown) => MessagingOutboundStatus | null; /** Group roster addresses from the raw inbound payload, excluding our line. */ participants?: (raw: unknown) => string[]; /** Group display name from the raw inbound payload. */ channelName?: (raw: unknown) => string | null; /** User-facing transport carried by the raw message when it differs from the provider. */ transport?: (raw: unknown) => string | null; /** * Optional team-room enrichment (workspace id, mention/ambient kind, bot * sender, reply thread) derived from the raw platform payload. */ enrichTeamRoom?: ( raw: unknown, base: MessagingInboundMessage, ) => Partial; } /** * MessagingSurface over the Chat SDK (github.com/vercel/chat): one bot * presence across every mounted platform. Orchestration stays upstream — * this class only translates between Chat SDK events/calls and the * provider-neutral contract. */ export class ChatSdkMessagingSurface implements MessagingSurface { private readonly chat: Chat>; private readonly byProvider = new Map(); private sink: ((event: MessagingInboundEvent) => Promise) | undefined; private initialized: Promise | undefined; constructor(platforms: MessagingPlatform[], options: { userName?: string } = {}) { if (platforms.length === 0) throw new Error("ChatSdkMessagingSurface needs >=1 platform"); for (const platform of platforms) this.byProvider.set(platform.provider, platform); this.chat = new Chat({ userName: options.userName ?? "rakazo", adapters: Object.fromEntries(platforms.map((p) => [p.provider, p.adapter])), state: createMemoryState(), // The SDK default ("drop") takes a per-conversation lock and discards // any message that arrives while it is held — and the LockError is // swallowed into waitUntil, so the webhook still ACKs 200 and the // vendor never retries. Two texts in quick succession would lose the // second. Each inbound message already runs inside its own webhook's // waitUntil task, so process them independently and let the per-message // client nonce downstream handle replay. concurrency: "concurrent", }); const deliver = async (thread: Thread, message: Message) => { const event = this.toInbound(thread, message); if (!event) return; try { await this.sink?.(event); } catch (error) { // processMessage hands waitUntil a swallowed promise, so track sink // failures here and turn them into HTTP 5xx after the drain. const drain = webhookDrain.getStore(); if (drain) drain.failed = true; throw error; } }; // Priority routing makes these disjoint: DMs, then @-mentions in // unsubscribed threads, then the catch-all pattern for everything else. // Subscribed threads skip the catch-all and only fire onSubscribedMessage // (chat SDK docs / dispatchToHandlersWithSignal); register deliver there too. this.chat.onDirectMessage(deliver); this.chat.onNewMention(deliver); this.chat.onSubscribedMessage(deliver); this.chat.onNewMessage(/(?:)/, deliver); } describe(): AdapterDescriptor<{ providers: string[] }> { return { id: "chat-sdk", contractVersion: "1", adapterVersion: "0.1.0", capabilities: { providers: [...this.byProvider.keys()] }, }; } platforms(): MessagingPlatformDescriptor[] { return [...this.byProvider.values()].map((platform) => ({ provider: platform.provider, capabilities: platform.capabilities, })); } onInbound(sink: (event: MessagingInboundEvent) => Promise): void { this.sink = sink; } handleWebhook(provider: string, request: Request): Promise | null { const platform = this.byProvider.get(provider); if (!platform) return null; return this.dispatchWebhook(platform, request); } async sendToThread( request: MessagingSendRequest, _context: AdapterContext, ): Promise { await this.ensureInitialized(); const sent = await this.chat.thread(request.threadId).post(request.body); const handle = "id" in sent && typeof sent.id === "string" ? sent.id : ""; return { handle }; } async openDirectThread( provider: string, address: string, _context: AdapterContext, ): Promise { const platform = this.byProvider.get(provider); if (!platform) throw new Error(`Unknown messaging provider: ${provider}`); if (platform.directThreadId) return platform.directThreadId(address); await this.ensureInitialized(); if (!platform.adapter.openDM) { throw new Error(`${provider} cannot open direct conversations`); } return platform.adapter.openDM(address); } async sendTyping(threadId: string, context: AdapterContext): Promise { const platform = this.byProvider.get(providerOfThreadId(threadId)); if (!platform?.capabilities.typing) return; await this.ensureInitialized(); // The Chat SDK adapter API takes no abort signal, so the underlying // request cannot be cancelled — but the caller's wait is still bounded. await raceWithSignal(platform.adapter.startTyping(threadId), context.signal); } /** * Proactively start the underlying Chat SDK instance. Webhook handling * and outbound sends already trigger this lazily (see ensureInitialized * below), but a polling-mode adapter (Telegram in "auto" mode with no * public webhook URL registered) needs its pull loop running before the * first inbound message can ever arrive, so long-running hosts call this * explicitly at startup instead of waiting on the first send. */ async initialize(): Promise { await this.ensureInitialized(); } /** * Stop Telegram getUpdates before exit. Chat.shutdown() only calls * adapter.disconnect(), which Telegram does not implement. */ async shutdown(): Promise { const pending = this.initialized; this.initialized = undefined; if (pending) await pending.catch(() => undefined); await this.stopPollingAdapters(); const chat = this.chat as { shutdown?: () => Promise }; await chat.shutdown?.().catch(() => undefined); } private ensureInitialized(): Promise { // Webhook handling initializes lazily inside the Chat SDK; proactive // sends from job runners need the explicit call. Chat keeps a rejected // initPromise until shutdown(), so clear that before allowing a retry. if (!this.initialized) { this.initialized = this.chat.initialize().catch(async (error) => { try { // Telegram may already be polling if Promise.all failed on another // adapter after Telegram's initialize started getUpdates. await this.stopPollingAdapters(); await (this.chat as { shutdown?: () => Promise }).shutdown?.(); } catch { // Best-effort reset of Chat's cached init failure. } finally { this.initialized = undefined; } throw error; }); } return this.initialized; } private async stopPollingAdapters(): Promise { for (const platform of this.byProvider.values()) { const adapter = platform.adapter as { stopPolling?: () => Promise }; await adapter.stopPolling?.().catch(() => undefined); } } private async dispatchWebhook(platform: MessagingPlatform, request: Request): Promise { const declared = Number(request.headers.get("content-length") ?? 0); if (declared > MESSAGING_WEBHOOK_MAX_BODY_BYTES) { return new Response("Payload too large", { status: 413 }); } const hasBody = request.method !== "GET" && request.method !== "HEAD"; const body = hasBody ? await readBoundedText(request, MESSAGING_WEBHOOK_MAX_BODY_BYTES) : ""; if (body === null) { return new Response("Payload too large", { status: 413 }); } const forwarded = hasBody ? new Request(request.url, { method: request.method, headers: request.headers, body }) : request; // Real Chat SDK adapters fire processMessage without awaiting it; they // only register the work via waitUntil. Drain that work before ACKing so // a vendor 200 cannot race ahead of provision/enqueue, and surface sink // failures as 5xx so the vendor retries. const drain = { pending: [] as Promise[], failed: false }; const response = await webhookDrain.run(drain, async () => { const adapterResponse = await this.chat.webhooks[platform.provider]!(forwarded, { waitUntil: (task) => { drain.pending.push(task); }, }); await Promise.all(drain.pending); return adapterResponse; }); if (drain.failed) { return new Response("Inbound processing failed", { status: 500 }); } // Status peeking runs only after the adapter verified and accepted the // request — a forged webhook must not be able to flip outbox rows. if (platform.peekStatus && response.status < 300 && body) { const status = parseStatus(platform, body); if (status) await this.sink?.(status); } return response; } private toInbound(thread: Thread, message: Message): MessagingInboundMessage | null { if (message.author.isMe || message.author.isSystem) return null; const provider = providerOfThreadId(thread.id); const platform = this.byProvider.get(provider); if (!platform) return null; const isDirect = thread.isDM; if (!isDirect && !platform.capabilities.groups) return null; const fromLabel = message.author.fullName || message.author.userName || null; const transport = platform.transport?.(message.raw) ?? null; const base: MessagingInboundMessage = { type: "message", provider, ...(transport ? { transport } : {}), handle: message.id, threadId: thread.id, isDirect, from: message.author.userId, fromLabel: fromLabel === message.author.userId ? null : fromLabel, channelName: isDirect ? null : (platform.channelName?.(message.raw) ?? null), participants: isDirect ? [] : (platform.participants?.(message.raw) ?? []), content: message.text ?? "", mediaUrl: message.attachments.find((attachment) => attachment.url)?.url ?? null, }; const enrichment = platform.enrichTeamRoom?.(message.raw, base) ?? {}; return { ...base, ...enrichment }; } } export function providerOfThreadId(threadId: string): string { return threadId.split(":", 1)[0] ?? ""; } function raceWithSignal(promise: Promise, signal: AbortSignal): Promise { if (signal.aborted) { promise.catch(() => undefined); return Promise.reject(signal.reason ?? new Error("Aborted")); } return new Promise((resolve, reject) => { const onAbort = () => { // The abandoned branch must not surface as an unhandled rejection. promise.catch(() => undefined); reject(signal.reason ?? new Error("Aborted")); }; signal.addEventListener("abort", onAbort, { once: true }); promise.then(resolve, reject).finally(() => signal.removeEventListener("abort", onAbort)); }); } /** * Read the body incrementally so an unauthenticated chunked request (no * trustworthy Content-Length) is cut off at the cap instead of being * buffered whole. Returns null once the cap is exceeded. */ async function readBoundedText(request: Request, maxBytes: number): Promise { const reader = request.body?.getReader(); if (!reader) return ""; const chunks: Uint8Array[] = []; let total = 0; for (;;) { const { done, value } = await reader.read(); if (done) break; total += value.byteLength; if (total > maxBytes) { await reader.cancel().catch(() => undefined); return null; } chunks.push(value); } return Buffer.concat(chunks).toString("utf8"); } function parseStatus(platform: MessagingPlatform, body: string): MessagingOutboundStatus | null { try { return platform.peekStatus?.(JSON.parse(body)) ?? null; } catch { return null; } }