import type { AdapterContext, AdapterDescriptor, MessagingCapabilities, MessagingInboundEvent, MessagingInboundMessage, MessagingPlatformDescriptor, MessagingSendRequest, MessagingSendResult, MessagingSurface, } from "@rakazo/adapter-kit"; import { type MessagingPlatform, providerOfThreadId } from "./chat-sdk-surface.js"; export interface TeamChatEmulatorInbound { handle?: string; threadId?: string; isDirect?: boolean; from?: string; fromLabel?: string | null; channelName?: string | null; participants?: string[]; content: string; mediaUrl?: string | null; workspaceId?: string; conversationKey?: string; kind?: MessagingInboundMessage["kind"]; replyThreadId?: string | null; senderIsBot?: boolean; participantNames?: string[]; } export interface TeamChatEmulatorSentPost { threadId: string; body: string; handle: string; } export interface MessagingTeamChatEmulatorOptions { provider?: string; capabilities?: Partial; } /** * Deterministic team-chat helpers for CI: build inbound fixtures and record * outbound sends without a live Slack network. */ export class MessagingTeamChatEmulator implements MessagingSurface { readonly provider: string; readonly sent: TeamChatEmulatorSentPost[] = []; private readonly capabilities: MessagingCapabilities; private sink: ((event: MessagingInboundEvent) => Promise) | undefined; private handleCounter = 0; private inboundCounter = 0; constructor(options: MessagingTeamChatEmulatorOptions = {}) { this.provider = options.provider ?? "teamchat-emulator"; if (!this.provider || this.provider.includes(":")) { throw new Error("Messaging emulator provider must be non-empty and cannot contain ':'"); } this.capabilities = { direct: options.capabilities?.direct ?? true, groups: options.capabilities?.groups ?? true, typing: options.capabilities?.typing ?? false, }; } buildInbound(partial: TeamChatEmulatorInbound): MessagingInboundMessage { this.inboundCounter += 1; const isDirect = partial.isDirect ?? false; const from = partial.from ?? "U-emulator"; return { type: "message", provider: this.provider, handle: partial.handle ?? this.allocateHandle("inbound"), threadId: partial.threadId ?? (isDirect ? `${this.provider}:dm:${from}` : `${this.provider}:room-${this.inboundCounter}`), isDirect, from, fromLabel: partial.fromLabel === undefined ? "Emulator User" : partial.fromLabel, channelName: isDirect ? null : (partial.channelName ?? "emulator-room"), participants: partial.participants ?? (isDirect ? [] : ["U-emulator", "U-peer"]), content: partial.content, mediaUrl: partial.mediaUrl ?? null, workspaceId: partial.workspaceId ?? "T-emulator", conversationKey: partial.conversationKey, kind: partial.kind, replyThreadId: partial.replyThreadId, senderIsBot: partial.senderIsBot, participantNames: partial.participantNames, }; } describe(): AdapterDescriptor<{ providers: string[] }> { return { id: "messaging-team-chat-emulator", contractVersion: "1", adapterVersion: "0.1.0", capabilities: { providers: [this.provider] }, }; } platforms(): MessagingPlatformDescriptor[] { return [{ provider: this.provider, capabilities: { ...this.capabilities } }]; } handleWebhook(provider: string, _request: Request): Promise | null { return provider === this.provider ? Promise.resolve( new Response("Use emitInbound() for emulator events", { status: 501, }), ) : null; } onInbound(sink: (event: MessagingInboundEvent) => Promise): void { this.sink = sink; } async emitInbound(partial: TeamChatEmulatorInbound): Promise { if (!this.sink) throw new Error("Team chat emulator has no inbound sink"); const event = this.buildInbound(partial); if (event.isDirect ? !this.capabilities.direct : !this.capabilities.groups) { throw new Error(`Team chat emulator does not support this conversation type`); } await this.sink(event); return event; } async sendToThread( request: MessagingSendRequest, _context: AdapterContext, ): Promise { this.assertProviderThread(request.threadId); return { handle: this.recordSend(request.threadId, request.body, "outbound"), }; } async openDirectThread( provider: string, address: string, _context: AdapterContext, ): Promise { if (provider !== this.provider) throw new Error(`Unknown messaging provider: ${provider}`); if (!this.capabilities.direct) { throw new Error(`${this.provider} emulator does not support direct messages`); } return `${this.provider}:dm:${address}`; } async sendTyping(threadId: string, _context: AdapterContext): Promise { if (!this.capabilities.typing || providerOfThreadId(threadId) !== this.provider) return; } resetWitnesses(): void { this.sent.length = 0; } /** Platform descriptor for mounting beside other messaging platforms in tests. */ createPlatform(): MessagingPlatform { return { provider: this.provider, capabilities: { ...this.capabilities }, // The adapter is only consulted for outbound post / openDM in unit tests that // drive ChatSdkMessagingSurface; prefer createRecordingMessagingSurface for // focused send recording without Chat SDK wiring. adapter: { version: "1.0.0", botUsername: "rakazo-emulator", postMessage: async (threadId: string, message: { text?: string }) => { const handle = this.recordSend(threadId, message.text ?? "", "outbound"); return { id: handle, html_url: null }; }, } as unknown as MessagingPlatform["adapter"], directThreadId: (address) => `${this.provider}:dm:${address}`, }; } /** Public handle allocator for test helpers that record outbound sends. */ allocateHandle(prefix: string): string { this.handleCounter += 1; return `${prefix}-${this.handleCounter}`; } /** Shared outbound witness used by compatibility helpers and the full surface. */ recordSend(threadId: string, body: string, prefix: string): string { const handle = this.allocateHandle(prefix); this.sent.push({ threadId, body, handle }); return handle; } private assertProviderThread(threadId: string): void { if (providerOfThreadId(threadId) !== this.provider) { throw new Error(`Thread does not belong to ${this.provider}: ${threadId}`); } } } /** Thin MessagingSurface stand-in that only records sendToThread for unit tests. */ export function createRecordingMessagingSurface(emulator: MessagingTeamChatEmulator): { sendToThread: (request: { threadId: string; body: string }) => Promise; sent: TeamChatEmulatorSentPost[]; } { return { sent: emulator.sent, async sendToThread(request) { return { handle: emulator.recordSend(request.threadId, request.body, "recorded"), }; }, }; }