chatd/src/outbound.ts
Rama main · réplica de lectura del repositorio original.
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<object>;
const OUTBOUND_ROUTE_PATTERN = /^\/v1\/platform\/([^/]+)\/([^/]+)$/;
const capabilityHandlers: Record<string, CapabilityHandler> = {
"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<string, PlatformChatAdapter>,
configuration: ChatdConfiguration,
personalGateways: Record<string, PersonalGateway> = {},
): (request: Request) => Promise<Response> {
return async function handleOutboundRequest(request: Request): Promise<Response> {
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<Response> {
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<ReplySendResponse> {
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<FileUpload[]> {
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<Buffer | null> {
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<Record<string, never>> {
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<Record<string, never>> {
return {};
}
async function handleReactionAdd(
adapter: PlatformChatAdapter,
_configuration: ChatdConfiguration,
requestBody: unknown,
): Promise<Record<string, never>> {
const requestDocument = parseReactionRequest(requestBody);
await adapter.addReaction("", requestDocument.messageID, requestDocument.emojiName);
return {};
}
async function handleReactionRemove(
adapter: PlatformChatAdapter,
_configuration: ChatdConfiguration,
requestBody: unknown,
): Promise<Record<string, never>> {
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<DirectMessageEnsureResponse> {
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<DirectMessageOpenResponse> {
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<ConversationsListResponse> {
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<PeopleListResponse> {
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<DirectMessagePostResponse> {
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<DirectMessageSendResponse> {
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<ChannelEnsureResponse> {
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<ReplySendResponse> {
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<Record<string, never>> {
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<string> {
const channelID = await resolveMessagePostChannelID(adapter, requestDocument);
return adapter.encodeThreadId({ channelId: channelID });
}
const messageSearchDefaultLimit = 25;
async function handleMessageSearch(
adapter: PlatformChatAdapter,
_configuration: ChatdConfiguration,
requestBody: unknown,
): Promise<MessageSearchResponse> {
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<MessageSearchCandidateDocument[]> {
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<string> {
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<HistoryFetchResponse> {
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<AttachmentImportResponse> {
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<string | undefined>;
};
function supportsChannelLookup(adapter: object): adapter is ChannelLookupAdapter {
return typeof (adapter as ChannelLookupAdapter).channelIdByName === "function";
}
async function handleMessagePost(
adapter: PlatformChatAdapter,
_configuration: ChatdConfiguration,
requestBody: unknown,
): Promise<MessagePostResponse> {
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<MessagePostRequest, "channelID" | "channelName">,
): Promise<string> {
// 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<IdentityResolveResponse> {
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<IdentitySelfResponse> {
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"]];
}