import type { AdapterPostableMessage, FileUpload } from "chat"; import { BuzzAdapter, type BuzzMessageSearchCandidate } from "./adapters/buzz/adapter.ts"; import type { MattermostAdapter } from "./adapters/mattermost/adapter.ts"; import type { ChatdConfiguration } from "./configuration.ts"; import { fetchAttachmentForDirectory, supportsAttachmentFetching } from "./outbound-attachments.ts"; import { supportsChannelProvisioning } from "./channels.ts"; import { ensureUserDirectMessageChannel, listUserConversations, pubkeyFromSecret, sendChannelMessageAsUser, sendDirectMessageAsUser, } from "./adapters/buzz/user-session.ts"; import { encodeHistoryCursor } from "./visible-context.ts"; import { buildVisibleContext, decodeHistoryCursor } from "./visible-context.ts"; import { parseAttachmentImportRequest, parseChannelEnsureRequest, parseDirectMessageEnsureRequest, parseDirectMessageOpenRequest, parseDirectMessagePostRequest, parseConversationsListRequest, parsePeopleListRequest, parseDirectMessageSendRequest, parseHistoryFetchRequest, parseIdentityResolveRequest, parseMessageDeleteRequest, parseMessageEditRequest, parseMessagePostRequest, parseMessageSearchRequest, parseProgressRequest, parseReactionRequest, parseReplySendRequest, } from "./outbound-parse.ts"; import { MessageChangeRefused, isElevatedIn, signerForMessageChange, signingKeyring } from "./message-ownership.ts"; import { ThreadPartlyDeleted, deleteThread } from "./adapters/buzz/thread-deletion.ts"; import { RelayLoginRefused } from "./adapters/buzz/relay-connection.ts"; import { personCapabilities, type PersonCapability } from "./personal/capabilities.ts"; import { requesterCapabilityNamed } from "./personal/as-the-requester.ts"; import { MalformedRequest } from "./personal/parse.ts"; import { messageSearchMostResults } from "./message-search.ts"; import { CredentialRefused, MediaRefused, UnsupportedByPlatform, type PersonalGateway } from "./personal/gateway.ts"; import { LastOwnerCannotLeave, NotChannelOwner, OwnershipPartlyHandedOver, TargetIsChannelOwner, } from "./adapters/buzz/user-channels.ts"; import type { AgentPartDocument, AttachmentImportResponse, ChannelEnsureResponse, DirectMessageEnsureResponse, DirectMessageOpenResponse, DirectMessagePostResponse, ConversationsListResponse, PeopleListResponse, DirectMessageSendResponse, HistoryFetchResponse, IdentityResolveResponse, IdentitySelfResponse, InputAttachmentDocument, MessagePostRequest, MessagePostResponse, MessageSearchCandidateDocument, MessageSearchRequest, MessageSearchResponse, ProgressRequest, ReplyAttachmentDocument, ReplySendRequest, ReplySendResponse, } from "./outbound-types.ts"; type PlatformChatAdapter = MattermostAdapter | BuzzAdapter; type CapabilityHandler = ( adapter: PlatformChatAdapter, configuration: ChatdConfiguration, requestDocument: unknown, ) => Promise; const OUTBOUND_ROUTE_PATTERN = /^\/v1\/platform\/([^/]+)\/([^/]+)$/; const capabilityHandlers: Record = { "reply.send": handleReplySend, "progress.start": handleProgressStart, "progress.stop": handleProgressStop, "reaction.add": handleReactionAdd, "reaction.remove": handleReactionRemove, "history.fetch": handleHistoryFetch, "attachments.import": handleAttachmentsImport, "identity.resolve": handleIdentityResolve, "identity.self": handleIdentitySelf, "channel.ensure": handleChannelEnsure, "dm.ensure": handleDirectMessageEnsure, "dm.open": handleDirectMessageOpen, "dm.post": handleDirectMessagePost, "dm.send": handleDirectMessageSend, "conversations.list": handleConversationsList, "channels.list": handleChannelsList, "people.list": handlePeopleList, "message.edit": handleMessageEdit, "message.post": handleMessagePost, "message.search": handleMessageSearch, "message_delete": handleMessageDelete, }; export function createOutboundHandler( adapters: Record, configuration: ChatdConfiguration, personalGateways: Record = {}, ): (request: Request) => Promise { return async function handleOutboundRequest(request: Request): Promise { if (request.method !== "POST") { return jsonResponse(405, { error: "method not allowed" }); } const routeMatch = OUTBOUND_ROUTE_PATTERN.exec(new URL(request.url).pathname); if (!routeMatch) { return jsonResponse(404, { error: "not found" }); } const [, platform, capabilityName] = routeMatch; if (!platform || !capabilityName) { return jsonResponse(404, { error: "not found" }); } const adapter = adapters[platform]; if (!adapter) { return jsonResponse(404, { error: `unknown platform ${platform}` }); } const personCapability = personCapabilityNamed(capabilityName, configuration); const handler = capabilityHandlers[capabilityName]; if (!personCapability && !handler) { return jsonResponse(404, { error: `unknown capability ${capabilityName}` }); } let requestDocument: unknown; try { requestDocument = await request.json(); } catch { return jsonResponse(400, { error: "expected a JSON body" }); } if (personCapability) { return answerAsPerson(personCapability, personalGateways[platform], platform, requestDocument, agentExternalIDOf(adapter)); } try { const responseDocument = await handler?.(adapter, configuration, requestDocument); return jsonResponse(200, responseDocument ?? {}); } catch (error) { if (error instanceof MalformedRequest) { return jsonResponse(400, { error: error.message }); } if (error instanceof ThreadPartlyDeleted) { return jsonResponse(409, { error: error.message, reason: error.reason, remaining: error.remaining }); } if (error instanceof MessageChangeRefused) { return jsonResponse(403, { error: error.message }); } return jsonResponse(502, { error: error instanceof Error ? error.message : String(error) }); } }; } function personCapabilityNamed(capabilityName: string, configuration: ChatdConfiguration): PersonCapability | undefined { return personCapabilities[capabilityName] ?? requesterCapabilityNamed(capabilityName, configuration.admindBaseURL); } function agentExternalIDOf(adapter: PlatformChatAdapter): string | undefined { if ("botUserId" in adapter && typeof adapter.botUserId === "string") return adapter.botUserId; if ("botPubkey" in adapter && typeof adapter.botPubkey === "string") return adapter.botPubkey; return undefined; } async function answerAsPerson( capability: PersonCapability, gateway: PersonalGateway | undefined, platform: string, requestDocument: unknown, agentExternalID?: string, ): Promise { if (!gateway) { return jsonResponse(404, { error: `${platform} cannot act as a person` }); } try { const answered = await capability(gateway, requestDocument, agentExternalID); return answered instanceof Response ? answered : jsonResponse(200, answered); } catch (error) { if (error instanceof MalformedRequest) { return jsonResponse(400, { error: error.message }); } if (error instanceof CredentialRefused) { return jsonResponse(401, { error: error.message }); } if (error instanceof RelayLoginRefused) { return jsonResponse(401, { error: error.message, reason: error.reason }); } if (error instanceof MediaRefused) { return jsonResponse(415, { error: error.message, refused: { status: error.status, reason: error.reason, digest: error.digest, sizeBytes: error.sizeBytes }, }); } if (error instanceof LastOwnerCannotLeave) { return jsonResponse(409, { error: error.message, reason: error.reason }); } if (error instanceof NotChannelOwner) { return jsonResponse(409, { error: error.message, reason: error.reason }); } if (error instanceof OwnershipPartlyHandedOver) { return jsonResponse(409, { error: error.message, reason: error.reason }); } if (error instanceof TargetIsChannelOwner) { return jsonResponse(409, { error: error.message, reason: error.reason }); } if (error instanceof UnsupportedByPlatform) { return jsonResponse(501, { error: error.message, reason: error.reason, platform: error.platform }); } if (error instanceof ThreadPartlyDeleted) { return jsonResponse(409, { error: error.message, reason: error.reason, remaining: error.remaining }); } return jsonResponse(502, { error: error instanceof Error ? error.message : String(error) }); } } function jsonResponse(statusCode: number, body: object): Response { return new Response(JSON.stringify(body), { status: statusCode, headers: { "Content-Type": "application/json" }, }); } async function handleReplySend( adapter: PlatformChatAdapter, _configuration: ChatdConfiguration, requestBody: unknown, ): Promise { const requestDocument = parseReplySendRequest(requestBody); const fileUploads = await buildFileUploads(requestDocument.attachments ?? []); const message = buildPostableMessage(requestDocument, fileUploads); // The thread says where the reply belongs and the answered message says what it // is a reply to. Without the second, somebody who wrote deep in a thread finds // the answer at the top of it. const answeringTags = answeringTagsFor(adapter, requestDocument.answeringMessageID); if (requestDocument.replyKind === "progress" && adapter instanceof BuzzAdapter) { return { dispatchID: (await adapter.postMessage(requestDocument.replyTargetID, message, [["reply-kind", "progress"], ...answeringTags])).id }; } const result = requestDocument.isError && adapter instanceof BuzzAdapter ? await adapter.postMessage(requestDocument.replyTargetID, message, [["reply-kind", "error"], ...answeringTags]) : await adapter.postMessage(requestDocument.replyTargetID, message, answeringTags); return { dispatchID: result.id }; } function buildPostableMessage( requestDocument: ReplySendRequest, fileUploads: FileUpload[], ): AdapterPostableMessage { const markdown = messageCarryingItsOptions(requestDocument); if (fileUploads.length > 0) { return { markdown, files: fileUploads }; } return markdown; } function messageCarryingItsOptions(requestDocument: ReplySendRequest): string { const options = requestDocument.interaction?.options ?? []; if (options.length === 0) { return requestDocument.message; } const written = requestDocument.message.trim(); const question = (requestDocument.interaction?.question ?? "").trim(); const stillToAsk = written.includes(question) ? "" : question; const numbered = options .map((option, position) => `${position + 1}. ${option.label}`) .join("\n"); return [written, stillToAsk, numbered].filter((part) => part !== "").join("\n\n"); } async function buildFileUploads(attachments: ReplyAttachmentDocument[]): Promise { const fileUploads: FileUpload[] = []; for (const attachment of attachments) { const fileBytes = await readAttachmentBytes(attachment); if (!fileBytes) { continue; } fileUploads.push({ data: fileBytes, filename: attachment.filename?.trim() || "attachment", mimeType: attachment.contentType, }); } return fileUploads; } async function readAttachmentBytes(attachment: ReplyAttachmentDocument): Promise { if (attachment.contentBase64) { return Buffer.from(attachment.contentBase64, "base64"); } if (attachment.devicePath) { const file = Bun.file(attachment.devicePath); if (await file.exists()) { return Buffer.from(await file.arrayBuffer()); } } return null; } async function handleProgressStart( adapter: PlatformChatAdapter, _configuration: ChatdConfiguration, requestBody: unknown, ): Promise> { const requestDocument = parseProgressRequest(requestBody); await adapter.startTyping(threadHoldingTheAnsweredMessage(adapter, requestDocument)); return {}; } function threadHoldingTheAnsweredMessage(adapter: PlatformChatAdapter, request: ProgressRequest): string { const answered = request.answeringMessageID?.trim(); if (!answered || !(adapter instanceof BuzzAdapter)) return request.replyTargetID; return adapter.historyScopeThreadId(request.replyTargetID, answered); } async function handleProgressStop(): Promise> { return {}; } async function handleReactionAdd( adapter: PlatformChatAdapter, _configuration: ChatdConfiguration, requestBody: unknown, ): Promise> { const requestDocument = parseReactionRequest(requestBody); await adapter.addReaction("", requestDocument.messageID, requestDocument.emojiName); return {}; } async function handleReactionRemove( adapter: PlatformChatAdapter, _configuration: ChatdConfiguration, requestBody: unknown, ): Promise> { const requestDocument = parseReactionRequest(requestBody); await adapter.removeReaction("", requestDocument.messageID, requestDocument.emojiName); return {}; } function requireBuzzAdapterForDirectMessages(adapter: PlatformChatAdapter): BuzzAdapter { if (!(adapter instanceof BuzzAdapter)) { throw new Error(`platform ${adapter.name} does not support user direct messages`); } return adapter; } async function handleDirectMessageEnsure( adapter: PlatformChatAdapter, configuration: ChatdConfiguration, requestBody: unknown, ): Promise { const requestDocument = parseDirectMessageEnsureRequest(requestBody); const buzzAdapter = requireBuzzAdapterForDirectMessages(adapter); if (requestDocument.channelId) { const replyTargetID = buzzAdapter.encodeThreadId({ channelId: requestDocument.channelId }); return { channelID: requestDocument.channelId, replyTargetID, historyCursor: encodeHistoryCursor({ threadId: replyTargetID }), userPubkeyHex: pubkeyFromSecret(requestDocument.userSecretHex), }; } const channel = await ensureUserDirectMessageChannel( requireBuzzRelayURL(configuration), requestDocument.userSecretHex, requestDocument.counterpartPubkeyHex ?? buzzAdapter.botPubkey, ); const replyTargetID = buzzAdapter.encodeThreadId({ channelId: channel.channelID }); const botUser = await buzzAdapter.getUser(buzzAdapter.botPubkey).catch(() => null); return { channelID: channel.channelID, replyTargetID, historyCursor: encodeHistoryCursor({ threadId: replyTargetID }), userPubkeyHex: channel.userPubkeyHex, botName: botUser?.fullName, botAvatarURL: botUser?.avatarUrl, }; } async function handleDirectMessageOpen( adapter: PlatformChatAdapter, _configuration: ChatdConfiguration, requestBody: unknown, ): Promise { const requestDocument = parseDirectMessageOpenRequest(requestBody); if (!("openDM" in adapter) || typeof adapter.openDM !== "function") { throw new Error(`platform ${adapter.name} does not support opening direct messages`); } const threadID = await adapter.openDM(requestDocument.externalUserID); return { conversationID: adapter.channelIdFromThreadId(threadID), replyTargetID: threadID, }; } async function handleChannelsList( adapter: PlatformChatAdapter, _configuration: ChatdConfiguration, _requestBody: unknown, ): Promise<{ channels: Array<{ channelID: string; name: string }> }> { if (!(adapter instanceof BuzzAdapter)) { throw new MalformedRequest(`platform ${adapter.name} has no channel directory`); } return { channels: await adapter.listChannels() }; } async function handleConversationsList( adapter: PlatformChatAdapter, configuration: ChatdConfiguration, requestBody: unknown, ): Promise { const requestDocument = parseConversationsListRequest(requestBody); requireBuzzAdapterForDirectMessages(adapter); const conversations = await listUserConversations( requireBuzzRelayURL(configuration), requestDocument.userSecretHex, ); return { conversations: conversations.map((conversation) => ({ id: conversation.channelID, name: conversation.name, kind: conversation.isDM ? "dm" : "group", avatarURL: conversation.avatarURL, participantExternalIDs: conversation.participantPubkeyHexes, })), }; } async function handlePeopleList( adapter: PlatformChatAdapter, configuration: ChatdConfiguration, requestBody: unknown, ): Promise { const requestDocument = parsePeopleListRequest(requestBody); const buzzAdapter = requireBuzzAdapterForDirectMessages(adapter); const people = await buzzAdapter.listPeople(pubkeyFromSecret(requestDocument.userSecretHex)); return { people }; } // The agent's own DM to a member: chatd signs with the key it already answers // as, so no secret crosses this boundary — the caller supplies only the // member's public key. async function handleDirectMessagePost( adapter: PlatformChatAdapter, configuration: ChatdConfiguration, requestBody: unknown, ): Promise { const requestDocument = parseDirectMessagePostRequest(requestBody); requireBuzzAdapterForDirectMessages(adapter); const relayURL = requireBuzzRelayURL(configuration); const agentSecretHex = configuration.buzz?.privateKeyHex; if (!agentSecretHex) throw new Error("buzz agent key is not configured"); const channel = await ensureUserDirectMessageChannel( relayURL, agentSecretHex, requestDocument.counterpartPubkeyHex, ); const messageID = await sendDirectMessageAsUser({ relayURL, userSecretHex: agentSecretHex, counterpartPubkeyHex: requestDocument.counterpartPubkeyHex, message: requestDocument.message, }); return { channelID: channel.channelID, messageID }; } async function handleDirectMessageSend( adapter: PlatformChatAdapter, configuration: ChatdConfiguration, requestBody: unknown, ): Promise { const requestDocument = parseDirectMessageSendRequest(requestBody); const buzzAdapter = requireBuzzAdapterForDirectMessages(adapter); const relayURL = requireBuzzRelayURL(configuration); const attachments = (requestDocument.attachments ?? []).map((attachment) => ({ contentBase64: attachment.contentBase64 ?? "", filename: attachment.filename ?? "", contentType: attachment.contentType ?? "application/octet-stream", })); if (requestDocument.channelId) { const sent = await sendChannelMessageAsUser({ relayURL, userSecretHex: requestDocument.userSecretHex, channelID: requestDocument.channelId, message: requestDocument.message, attachments, replyToRootId: requestDocument.replyToRootId, }); return { channelID: requestDocument.channelId, replyTargetID: buzzAdapter.encodeThreadId({ channelId: requestDocument.channelId }), messageID: sent.id, }; } // A person writing from the messenger web is writing to the agent, and says // so by naming nobody. A message the agent was asked to pass on is addressed // to the colleague, and goes out under the name of whoever asked for it. const counterpartPubkeyHex = requestDocument.counterpartPubkeyHex ?? buzzAdapter.botPubkey; const channel = await ensureUserDirectMessageChannel( relayURL, requestDocument.userSecretHex, counterpartPubkeyHex, ); const messageID = await sendDirectMessageAsUser({ relayURL, userSecretHex: requestDocument.userSecretHex, counterpartPubkeyHex, message: requestDocument.message, attachments, }); return { channelID: channel.channelID, replyTargetID: buzzAdapter.encodeThreadId({ channelId: channel.channelID }), messageID, }; } function requireBuzzRelayURL(configuration: ChatdConfiguration): string { const relayURL = configuration.buzz?.relayURL; if (!relayURL) throw new Error("buzz relay is not configured"); return relayURL; } async function handleChannelEnsure( adapter: PlatformChatAdapter, _configuration: ChatdConfiguration, requestBody: unknown, ): Promise { const requestDocument = parseChannelEnsureRequest(requestBody); if (!supportsChannelProvisioning(adapter)) { throw new Error(`platform ${adapter.name} cannot provision channels`); } return await adapter.ensureChannel(requestDocument); } async function handleMessageEdit( adapter: PlatformChatAdapter, configuration: ChatdConfiguration, requestBody: unknown, ): Promise { const requestDocument = parseMessageEditRequest(requestBody); const fileUploads = await buildFileUploads(requestDocument.attachments ?? []); if (fileUploads.length > 0 && !(adapter instanceof BuzzAdapter)) { throw new MalformedRequest(`platform ${adapter.name} cannot attach files to an edit`); } const message: AdapterPostableMessage = fileUploads.length > 0 ? { markdown: requestDocument.message, files: fileUploads } : requestDocument.message; const signerSecretHex = adapter instanceof BuzzAdapter ? await signerForMessageChange( adapter, requestDocument.messageID, requestDocument.requesterPubkeyHex, configuration.admindBaseURL, ) : undefined; const result = await adapter.editMessage( requestDocument.replyTargetID, requestDocument.messageID, message, signerSecretHex, ); return { dispatchID: result.id }; } async function handleMessageDelete( adapter: PlatformChatAdapter, configuration: ChatdConfiguration, requestBody: unknown, ): Promise> { const requestDocument = parseMessageDeleteRequest(requestBody); if (!(adapter instanceof BuzzAdapter)) { await adapter.deleteMessage(requestDocument.replyTargetID, requestDocument.messageID); return {}; } const signerSecretHex = await signerForMessageChange( adapter, requestDocument.messageID, requestDocument.requesterPubkeyHex, configuration.admindBaseURL, ); const root = await adapter.readMessageEvent(requestDocument.messageID); const channelID = await adapter.channelOwningMessage(requestDocument.replyTargetID, requestDocument.messageID); const keyring = signingKeyring(configuration.admindBaseURL); await deleteThread({ relay: adapter, channelID, rootEventId: requestDocument.messageID, mayClearReplies: async () => { if (!root) return false; if (root.pubkey !== adapter.botPubkey) return true; return isElevatedIn(adapter, channelID, (requestDocument.requesterPubkeyHex ?? "").trim().toLowerCase(), true); }, deleteRoot: () => adapter.deleteMessage(requestDocument.replyTargetID, requestDocument.messageID, signerSecretHex), deleteReply: async (reply) => adapter.deleteMessage( adapter.encodeThreadId({ channelId: channelID }), reply.id, reply.pubkey === adapter.botPubkey ? undefined : await keyring(reply.pubkey, reply.id), ), }); return {}; } // A caller that was never spoken to in a channel holds no cursor for it, so the // channel's own name is enough to start reading, the way message.post already // resolves one to post into. async function historyThreadIdForChannel( adapter: PlatformChatAdapter, requestDocument: { channelID?: string; channelName?: string }, ): Promise { const channelID = await resolveMessagePostChannelID(adapter, requestDocument); return adapter.encodeThreadId({ channelId: channelID }); } const messageSearchDefaultLimit = 25; async function handleMessageSearch( adapter: PlatformChatAdapter, _configuration: ChatdConfiguration, requestBody: unknown, ): Promise { const requestDocument = parseMessageSearchRequest(requestBody); if (!(adapter instanceof BuzzAdapter)) { throw new MalformedRequest(`platform ${adapter.name} does not serve message.search`); } const hasMessageIDs = (requestDocument.messageIDs?.length ?? 0) > 0; // An ID names one exact message wherever it lives, so reading by ID needs no // channel; every other search reads a channel's record and must name one. const channelId = hasMessageIDs ? "" : await resolveMessageSearchChannelId(adapter, requestDocument); const candidates = await adapter.searchMessages({ channelId, rootEventId: requestDocument.rootMessageID?.trim() || undefined, messageIds: hasMessageIDs ? requestDocument.messageIDs : undefined, authorPubkeyHex: messageSearchAuthorPubkey(adapter, requestDocument), queries: requestDocument.queries ?? [], limit: messageSearchLimit(requestDocument.limit), }); return { channelID: channelId || candidates[0]?.channelID || "", candidates: await messageSearchCandidateDocuments(adapter, candidates, requestDocument.requesterPubkeyHex), }; } // These flags are a hint the caller can plan with, not a permission: the relay // decides every change when it is asked for. An edit belongs to whoever wrote // the message, and a deletion also to whoever administers the channel. async function messageSearchCandidateDocuments( adapter: BuzzAdapter, candidates: BuzzMessageSearchCandidate[], requesterPubkeyHex: string | undefined, ): Promise { const actorPubkeyHex = (requesterPubkeyHex ?? "").trim(); const documents: MessageSearchCandidateDocument[] = []; for (const candidate of candidates) { const editable = candidate.authorPubkeyHex === adapter.botPubkey || (actorPubkeyHex !== "" && candidate.authorPubkeyHex === actorPubkeyHex); const deletable = editable || (await isElevatedIn(adapter, candidate.channelID, actorPubkeyHex)); documents.push({ ...candidate, editable, deletable }); } return documents; } function messageSearchLimit(limit: number | undefined): number { if (!limit || limit <= 0) return messageSearchDefaultLimit; return Math.min(limit, messageSearchMostResults); } function messageSearchAuthorPubkey( adapter: BuzzAdapter, requestDocument: MessageSearchRequest, ): string | undefined { switch (requestDocument.authoredBy?.trim() ?? "") { case "assistant": return adapter.botPubkey; case "requester": { const pubkey = requestDocument.requesterPubkeyHex?.trim(); if (!pubkey) { throw new MalformedRequest("authoredBy=requester needs requesterPubkeyHex"); } return pubkey; } default: return undefined; } } async function resolveMessageSearchChannelId( adapter: BuzzAdapter, requestDocument: MessageSearchRequest, ): Promise { if (requestDocument.channelID?.trim()) { return requestDocument.channelID.trim(); } if (requestDocument.channelName?.trim()) { const channelId = await adapter.channelIdByName(requestDocument.channelName); if (!channelId) { throw new MalformedRequest( `no channel named ${JSON.stringify(requestDocument.channelName)} exists on ${adapter.name}`, ); } return channelId; } const replyTargetID = requestDocument.replyTargetID?.trim(); if (!replyTargetID) { throw new MalformedRequest("message.search requires replyTargetID, channelID, or channelName"); } return adapter.decodeThreadId(replyTargetID).channelId; } async function handleHistoryFetch( adapter: PlatformChatAdapter, _configuration: ChatdConfiguration, requestBody: unknown, ): Promise { const requestDocument = parseHistoryFetchRequest(requestBody); const { threadId, cursor } = requestDocument.historyCursor ? decodeHistoryCursor(requestDocument.historyCursor) : { threadId: requestDocument.threadID ?? (await historyThreadIdForChannel(adapter, requestDocument)), cursor: undefined, }; const context = await buildVisibleContext(adapter, threadId, { cursor, limit: requestDocument.limit }); return { messages: context.messages, hasMoreBefore: context.hasMoreBefore, historyCursor: context.historyCursor, channelID: context.channelID, channelName: context.channelName, conversationType: context.conversationType, }; } async function handleAttachmentsImport( adapter: PlatformChatAdapter, _configuration: ChatdConfiguration, requestBody: unknown, ): Promise { const requestDocument = parseAttachmentImportRequest(requestBody); const platformName = adapter.name; if (!supportsAttachmentFetching(adapter)) { return { inputParts: [], inputAttachments: requestDocument.inputAttachments.map((attachment) => ({ ...attachment, isAvailable: false, errorCode: "unsupported_platform", message: `platform ${platformName} cannot fetch attachments`, })), }; } const importedAttachments = await Promise.all( requestDocument.inputAttachments.map((attachment) => fetchAttachmentForDirectory(adapter, requestDocument.targetDirectoryPath, attachment), ), ); return { inputAttachments: importedAttachments, inputParts: importedAttachments .filter((attachment) => attachment.isAvailable && attachment.path) .map((attachment) => agentPartForAttachment(adapter.name, attachment, requestDocument.messageID)), }; } function agentPartForAttachment( platform: string, attachment: InputAttachmentDocument, messageID: string, ): AgentPartDocument { const isImage = (attachment.contentType ?? "").startsWith("image/"); const source = { platform: attachment.platform || platform, messageID, fileID: attachment.fileID }; if (isImage) { return { type: "image", image: { path: attachment.path ?? "", filename: attachment.filename, mimeType: attachment.contentType }, source, }; } return { type: "file", file: { path: attachment.path ?? "", filename: attachment.filename, contentType: attachment.contentType, sizeBytes: attachment.sizeBytes, }, source, }; } type ChannelLookupAdapter = { channelIdByName(name: string): Promise; }; function supportsChannelLookup(adapter: object): adapter is ChannelLookupAdapter { return typeof (adapter as ChannelLookupAdapter).channelIdByName === "function"; } async function handleMessagePost( adapter: PlatformChatAdapter, _configuration: ChatdConfiguration, requestBody: unknown, ): Promise { const requestDocument = parseMessagePostRequest(requestBody); const keptAttachments = requestDocument.keptAttachments; if (keptAttachments.length > 0 && !(adapter instanceof BuzzAdapter)) { throw new MalformedRequest(`${adapter.name} cannot link a file its messenger already keeps`); } const fileUploads = await buildFileUploads(requestDocument.attachments ?? []); const message: AdapterPostableMessage = fileUploads.length > 0 ? { markdown: requestDocument.message, files: fileUploads } : requestDocument.message; if (requestDocument.threadID) { const posted = adapter instanceof BuzzAdapter ? await adapter.postMessage(requestDocument.threadID, message, [], keptAttachments) : await adapter.postMessage(requestDocument.threadID, message); return { messageID: posted.id }; } const channelID = await resolveMessagePostChannelID(adapter, requestDocument); const posted = adapter instanceof BuzzAdapter ? await adapter.postChannelMessage(channelID, message, keptAttachments) : await adapter.postChannelMessage(channelID, message); return { messageID: posted.id, channelID }; } async function resolveMessagePostChannelID( adapter: PlatformChatAdapter, requestDocument: Pick, ): Promise { // A channelID arrives from a model that may have reused whatever id it last // saw. When the caller also names the channel, the name is resolved and has // to agree; a mismatch fails closed instead of posting into the wrong room. if (requestDocument.channelID && requestDocument.channelName && supportsChannelLookup(adapter)) { const resolvedID = await adapter.channelIdByName(requestDocument.channelName); if (resolvedID && resolvedID !== requestDocument.channelID) { throw new MalformedRequest( `channelID ${requestDocument.channelID} is not the channel named ${JSON.stringify(requestDocument.channelName)} (${resolvedID}); pass one or the other`, ); } return resolvedID ?? requestDocument.channelID; } if (requestDocument.channelID) { return requestDocument.channelID; } const channelName = requestDocument.channelName ?? ""; if (!supportsChannelLookup(adapter)) { throw new MalformedRequest(`platform ${adapter.name} cannot resolve a channel by name; pass channelID`); } const channelID = await adapter.channelIdByName(channelName); if (!channelID) { throw new MalformedRequest(`no channel named ${JSON.stringify(channelName)} exists on ${adapter.name}`); } return channelID; } async function handleIdentityResolve( adapter: PlatformChatAdapter, _configuration: ChatdConfiguration, requestBody: unknown, ): Promise { const requestDocument = parseIdentityResolveRequest(requestBody); if (!adapter.getUser) { return {}; } const user = await adapter.getUser(requestDocument.senderID); if (!user) { return {}; } return { displayName: user.fullName, email: user.email }; } // The agent's own platform identity lives with the adapter that signs as it, // so a caller that must name the bot asks here instead of deriving the key. async function handleIdentitySelf( adapter: PlatformChatAdapter, _configuration: ChatdConfiguration, _requestBody: unknown, ): Promise { if (!(adapter instanceof BuzzAdapter)) { throw new MalformedRequest(`platform ${adapter.name} does not serve identity.self`); } return { pubkeyHex: adapter.botPubkey, name: adapter.userName }; } // Only buzz carries a marked reply tag; the other adapters thread by the target // they were already given and take nothing here. function answeringTagsFor(adapter: unknown, answeringMessageID: string | undefined): string[][] { const answered = answeringMessageID?.trim(); if (!answered || !(adapter instanceof BuzzAdapter)) return []; return [["e", answered, "", "reply"]]; }