evOo-local/packages/adapters/src/chat-sdk-surface.ts
cynarAI 6c7eed7580 Initial clean evOo-local from f00577b
Fresh history cut from cynarAI/evoo antigravity-handoff/project-timeline
(Voice First + project timeline). Brand docs/titles as evOo; keep @rakazo/*
packages as tech debt. Excludes node_modules, caches, secrets, voice blobs.
2026-09-20 17:15:16 +02:00

338 lines
13 KiB
TypeScript

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<unknown>[];
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<MessagingInboundMessage>;
}
/**
* 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<Record<string, Adapter>>;
private readonly byProvider = new Map<string, MessagingPlatform>();
private sink: ((event: MessagingInboundEvent) => Promise<void>) | undefined;
private initialized: Promise<void> | 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>): void {
this.sink = sink;
}
handleWebhook(provider: string, request: Request): Promise<Response> | null {
const platform = this.byProvider.get(provider);
if (!platform) return null;
return this.dispatchWebhook(platform, request);
}
async sendToThread(
request: MessagingSendRequest,
_context: AdapterContext,
): Promise<MessagingSendResult> {
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<string> {
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<void> {
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<void> {
await this.ensureInitialized();
}
/**
* Stop Telegram getUpdates before exit. Chat.shutdown() only calls
* adapter.disconnect(), which Telegram does not implement.
*/
async shutdown(): Promise<void> {
const pending = this.initialized;
this.initialized = undefined;
if (pending) await pending.catch(() => undefined);
await this.stopPollingAdapters();
const chat = this.chat as { shutdown?: () => Promise<void> };
await chat.shutdown?.().catch(() => undefined);
}
private ensureInitialized(): Promise<void> {
// 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<void> }).shutdown?.();
} catch {
// Best-effort reset of Chat's cached init failure.
} finally {
this.initialized = undefined;
}
throw error;
});
}
return this.initialized;
}
private async stopPollingAdapters(): Promise<void> {
for (const platform of this.byProvider.values()) {
const adapter = platform.adapter as { stopPolling?: () => Promise<void> };
await adapter.stopPolling?.().catch(() => undefined);
}
}
private async dispatchWebhook(platform: MessagingPlatform, request: Request): Promise<Response> {
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<unknown>[], 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<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
if (signal.aborted) {
promise.catch(() => undefined);
return Promise.reject(signal.reason ?? new Error("Aborted"));
}
return new Promise<T>((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<string | null> {
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;
}
}