Ctrl K
ComunidadGuías…

chatd/src/visible-context.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import { convertEmojiPlaceholders } from "chat";
import type { FetchOptions, Message, ThreadInfo, UserInfo } from "chat";

export type AddressingDocument = {
	botMentioned: boolean;
	otherPersonMentioned: boolean;
};

export type ReactionSummary = {
	emoji: string;
	count: number;
	imageURL?: string;
};

export type MessageInputAttachmentDocument = {
	platform: string;
	url: string;
	messageID: string;
	filename?: string;
	contentType?: string;
	sizeBytes?: number;
};

export type CustomEmojiDocument = {
	name: string;
	url: string;
};

export type VisibleContextMessageDocument = {
	id: string;
	threadRootId?: string;
	speaker: string;
	speakerHandle?: string;
	senderId?: string;
	senderAvatarUrl?: string;
	text: string;
	sentAt?: string;
	isBot?: boolean;
	isError?: boolean;
	reactions?: ReactionSummary[];
	inputAttachments?: MessageInputAttachmentDocument[];
	customEmoji?: CustomEmojiDocument[];
};

export type VisibleContextSenderDocument = {
	platform: string;
	senderID: string;
	handle?: string;
	email?: string;
	name?: string;
};

export type VisibleContextDocument = {
	messages: VisibleContextMessageDocument[];
	// Whether the messages are how other conversations in the same place opened,
	// rather than the conversation being continued.
	messagesOpenOtherExchanges: boolean;
	hasMoreBefore: boolean;
	historyCursor: string;
	sender?: VisibleContextSenderDocument;
	conversationType?: string;
	channelID?: string;
	channelName?: string;
	roomMemberEmails?: string[];
};

type ContextMessage = {
	id: string;
	text: string;
	author: { userId: string; userName: string; fullName: string; isBot?: boolean | "unknown" };
	metadata: { dateSent: Date };
	attachments?: Array<{ type?: string; url?: string; name?: string; mimeType?: string; size?: number }>;
	raw?: unknown;
};

export type ContextCapableAdapter = {
	name: string;
	fetchMessages(
		threadId: string,
		options?: FetchOptions,
	): Promise<{ messages: ContextMessage[]; nextCursor?: string }>;
	fetchThread(threadId: string): Promise<ThreadInfo>;
	getUser(userId: string): Promise<UserInfo | null>;
	fetchReactions?(scopeThreadId: string): Promise<Map<string, ReactionSummary[]>>;
	threadRootIdOf?(raw: unknown): string | undefined;
	senderAvatarUrlOf?(senderId: string): string | undefined;
	roomMemberEmailsOf?(threadId: string): Promise<string[] | undefined>;
};

export type NormalizedMessageEdit = {
	eventID: string;
	threadID: string;
	message: Message;
};

export type NormalizedPlatformAdapter = ContextCapableAdapter & {
	historyScopeThreadId(threadId: string, messageId: string): string;
	addressingOf(raw: unknown): AddressingDocument;
	timeZoneOf?(raw: unknown): string | undefined;
	onMessageEdit?(handler: (edit: NormalizedMessageEdit) => Promise<void>): void;
};

export type HistoryCursorState = {
	threadId: string;
	cursor?: string;
};

export function encodeHistoryCursor(state: HistoryCursorState): string {
	return Buffer.from(JSON.stringify(state), "utf8").toString("base64url");
}

export function decodeHistoryCursor(historyCursor: string): HistoryCursorState {
	try {
		const decoded: unknown = JSON.parse(Buffer.from(historyCursor, "base64url").toString("utf8"));
		if (
			typeof decoded === "object" &&
			decoded !== null &&
			"threadId" in decoded &&
			typeof decoded.threadId === "string"
		) {
			const cursor = "cursor" in decoded && typeof decoded.cursor === "string" ? decoded.cursor : undefined;
			return { threadId: decoded.threadId, cursor };
		}
	} catch {
		return { threadId: historyCursor };
	}
	return { threadId: historyCursor };
}

const DEFAULT_HISTORY_LIMIT = 20;

export async function buildVisibleContext(
	adapter: ContextCapableAdapter,
	scopeThreadId: string,
	options: {
		beforeMessageId?: string;
		senderId?: string;
		cursor?: string;
		limit?: number;
		onlyExchangeOpenings?: boolean;
		withRoomMembers?: boolean;
	} = {},
): Promise<VisibleContextDocument> {
	const limit = options.limit && options.limit > 0 ? options.limit : DEFAULT_HISTORY_LIMIT;
	const [fetchResult, threadInfo, senderInfo, reactionsById, roomMemberEmails] = await Promise.all([
		adapter.fetchMessages(scopeThreadId, { cursor: options.cursor, limit: limit + 1, direction: "backward" }),
		adapter.fetchThread(scopeThreadId).catch(() => null),
		options.senderId ? adapter.getUser(options.senderId).catch(() => null) : Promise.resolve(null),
		adapter.fetchReactions?.(scopeThreadId).catch(() => null) ?? Promise.resolve(null),
		roomMemberEmailsFor(adapter, scopeThreadId, options.withRoomMembers === true),
	]);
	let previousMessages = messagesBefore(fetchResult.messages, options.beforeMessageId).filter(
		(candidate) => !isProgressMessage(candidate.raw)
	);
	const hasExchangeOpeningScope = options.onlyExchangeOpenings === true && threadInfo?.isDM !== true;
	if (hasExchangeOpeningScope) {
		// What another exchange opened with says what it is about. What was said
		// inside it belongs to whoever is in it, and read here it turns one
		// conversation into several that look like one.
		previousMessages = previousMessages.filter(
			(candidate) => (adapter.threadRootIdOf?.(candidate.raw) ?? candidate.id) === candidate.id
		);
	}
	const hasMoreBefore = Boolean(fetchResult.nextCursor) || previousMessages.length > limit;
	if (previousMessages.length > limit) {
		previousMessages = previousMessages.slice(-limit);
	}
	return {
		messagesOpenOtherExchanges: hasExchangeOpeningScope,
		messages: previousMessages.map((message) =>
			toVisibleContextMessage(
				adapter.name,
				message,
				reactionsById?.get(message.id),
				adapter.threadRootIdOf?.(message.raw),
				adapter.senderAvatarUrlOf?.(message.author.userId),
			),
		),
		hasMoreBefore,
		historyCursor: encodeHistoryCursor({ threadId: scopeThreadId, cursor: fetchResult.nextCursor }),
		sender: senderInfo ? toContextSender(adapter.name, senderInfo) : undefined,
		conversationType: threadInfo ? (threadInfo.isDM ? "direct" : "channel") : undefined,
		channelID: threadInfo?.channelId,
		channelName: threadInfo?.channelName,
		roomMemberEmails,
	};
}

async function roomMemberEmailsFor(
	adapter: ContextCapableAdapter,
	scopeThreadId: string,
	isWanted: boolean,
): Promise<string[] | undefined> {
	if (!isWanted || !adapter.roomMemberEmailsOf) return undefined;
	return adapter.roomMemberEmailsOf(scopeThreadId).catch((reason) => {
		console.error("[context] reading who is in the room failed:", reason instanceof Error ? reason.message : reason);
		return undefined;
	});
}

export function emptyVisibleContext(scopeThreadId: string): VisibleContextDocument {
	return {
		messages: [],
		messagesOpenOtherExchanges: false,
		hasMoreBefore: true,
		historyCursor: encodeHistoryCursor({ threadId: scopeThreadId }),
	};
}

function messagesBefore(messages: ContextMessage[], beforeMessageId?: string): ContextMessage[] {
	if (!beforeMessageId) return [...messages];
	const boundaryIndex = messages.findIndex((message) => message.id === beforeMessageId);
	if (boundaryIndex >= 0) return messages.slice(0, boundaryIndex);
	return messages.filter((message) => message.id !== beforeMessageId);
}

function toVisibleContextMessage(
	platform: string,
	message: ContextMessage,
	reactions?: ReactionSummary[],
	threadRootId?: string,
	senderAvatarUrl?: string,
): VisibleContextMessageDocument {
	return {
		id: message.id,
		// Which exchange the message belongs to, not who it answers. A message that
		// starts one belongs to itself, and leaving that out would put a root and
		// its own replies in different exchanges.
		threadRootId: threadRootId ?? message.id,
		speaker: message.author.fullName || message.author.userName,
		speakerHandle: message.author.userName,
		senderId: message.author.userId,
		senderAvatarUrl,
		text: convertEmojiPlaceholders(message.text, "gchat"),
		sentAt: message.metadata.dateSent.toISOString(),
		isBot: message.author.isBot === true,
		isError: isErrorMessage(message.raw),
		reactions: reactions && reactions.length > 0 ? reactions : undefined,
		inputAttachments: inputAttachmentsOfMessage(platform, message),
		customEmoji: customEmojiOf(message.raw),
	};
}

function customEmojiOf(raw: unknown): CustomEmojiDocument[] | undefined {
	if (typeof raw !== "object" || raw === null || !("tags" in raw)) return undefined;
	const { tags } = raw as { tags?: unknown };
	if (!Array.isArray(tags)) return undefined;
	const documents: CustomEmojiDocument[] = [];
	for (const tag of tags) {
		if (!Array.isArray(tag) || tag[0] !== "emoji") continue;
		const name = tag[1];
		const url = tag[2];
		if (typeof name === "string" && typeof url === "string" && name && url) {
			documents.push({ name, url });
		}
	}
	return documents.length > 0 ? documents : undefined;
}

function isErrorMessage(raw: unknown): boolean {
	if (typeof raw !== "object" || raw === null || !("tags" in raw)) return false;
	const { tags } = raw as { tags?: unknown };
	if (!Array.isArray(tags)) return false;
	return tags.some((tag) => Array.isArray(tag) && tag[0] === "reply-kind" && tag[1] === "error");
}

export function inputAttachmentsOfMessage(
	platform: string,
	message: ContextMessage,
): MessageInputAttachmentDocument[] | undefined {
	const documents: MessageInputAttachmentDocument[] = [];
	for (const attachment of message.attachments ?? []) {
		if (!attachment.url) continue;
		documents.push({
			platform,
			url: attachment.url,
			messageID: message.id,
			filename: attachment.name,
			contentType: attachment.mimeType,
			sizeBytes: attachment.size,
		});
	}
	return documents.length > 0 ? documents : undefined;
}

function toContextSender(platform: string, user: UserInfo): VisibleContextSenderDocument {
	return {
		platform,
		senderID: user.userId,
		handle: user.userName,
		email: user.email,
		name: user.fullName || user.userName,
	};
}


// A turn writes what it is doing into a message somebody can watch. It is not
// something anyone said, so reading it back turns the agent's own working notes
// into conversation.
function isProgressMessage(raw: unknown): boolean {
	const tags = (raw as { tags?: unknown[] })?.tags;
	if (!Array.isArray(tags)) return false;
	return tags.some((tag) => Array.isArray(tag) && tag[0] === "reply-kind" && tag[1] === "progress");
}