evOo-local/packages/adapters/src/messaging-team-chat-emulator.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

215 lines
7.1 KiB
TypeScript

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<MessagingCapabilities>;
}
/**
* 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<void>) | 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<Response> | null {
return provider === this.provider
? Promise.resolve(
new Response("Use emitInbound() for emulator events", {
status: 501,
}),
)
: null;
}
onInbound(sink: (event: MessagingInboundEvent) => Promise<void>): void {
this.sink = sink;
}
async emitInbound(partial: TeamChatEmulatorInbound): Promise<MessagingInboundMessage> {
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<MessagingSendResult> {
this.assertProviderThread(request.threadId);
return {
handle: this.recordSend(request.threadId, request.body, "outbound"),
};
}
async openDirectThread(
provider: string,
address: string,
_context: AdapterContext,
): Promise<string> {
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<void> {
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<MessagingSendResult>;
sent: TeamChatEmulatorSentPost[];
} {
return {
sent: emulator.sent,
async sendToThread(request) {
return {
handle: emulator.recordSend(request.threadId, request.body, "recorded"),
};
},
};
}