Ctrl K
ComunidadGuías…

scripts/lib/telegram-topic-registry.ts

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

Archivo original ↗Preguntar a KiBot
/**
 * Comparing the owner's `tg-hq` topic registry with Reccado's D1 topic mappings.
 *
 * The Bot API cannot list a forum's topics, so a thread id lives in two places
 * once a mailbox topic exists: the `tg-hq` registry (a JSON field in 1Password,
 * slug -> { id, name }) that recorded it at creation, and D1 `telegram_topics`
 * (chat_id, mailbox_id, topic_id) that Reccado delivers to. Nothing keeps them in
 * step, and a drift is silent: Reccado posts to a thread the owner no longer
 * knows by name, or a topic the owner made for a mailbox never receives mail.
 *
 * Pure: no node APIs, no network. The doctor reads both sides and hands them in.
 * It must never probe Telegram to settle a disagreement — the only existence
 * probe the Bot API offers is `editForumTopic`, which renames the topic.
 */

export type RegistryTopic = { slug: string; id: number; name: string };

export type TopicMapping = {
	chatId: string;
	mailboxId: string;
	/** The mailbox's primary address; null when the mailbox row is gone. */
	address: string | null;
	topicId: number;
};

export type TopicFinding = {
	status: "pass" | "warn" | "info";
	message: string;
	fix?: string;
};

export type ParsedRegistry = { ok: true; topics: RegistryTopic[] } | { ok: false; error: string };

/** tg-hq's slug rule: trimmed, lowercased, whitespace runs joined by "-". */
function slugify(value: string): string {
	return value.trim().toLowerCase().split(/\s+/).filter(Boolean).join("-");
}

/**
 * The `topics` field as tg-hq validates it: an object of slug -> { id: int,
 * name: non-empty string }. Empty text is an empty registry, as in tg-hq.
 */
export function parseTopicRegistry(raw: string): ParsedRegistry {
	if (!raw.trim()) return { ok: true, topics: [] };
	let parsed: unknown;
	try {
		parsed = JSON.parse(raw);
	} catch {
		return { ok: false, error: "the topics field is not valid JSON" };
	}
	if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
		return { ok: false, error: "the topics field is not a JSON object" };
	}
	const topics: RegistryTopic[] = [];
	for (const [slug, entry] of Object.entries(parsed as Record<string, unknown>)) {
		const value = entry as { id?: unknown; name?: unknown } | null;
		if (!value || typeof value !== "object") {
			return { ok: false, error: `entry "${slug}" is not an object` };
		}
		if (typeof value.id !== "number" || !Number.isInteger(value.id)) {
			return { ok: false, error: `entry "${slug}" has a non-integer id` };
		}
		if (typeof value.name !== "string" || !value.name.trim()) {
			return { ok: false, error: `entry "${slug}" has no usable name` };
		}
		topics.push({ slug, id: value.id, name: value.name });
	}
	topics.sort((a, b) => a.slug.localeCompare(b.slug));
	return { ok: true, topics };
}

const MAILBOX_PREFIX = /^(correo|mail|email|inbox|buzon)-/;
const MAILBOX_NAME = /^(correo|mail|e-?mail|inbox|buz[oó]n)\b/i;

/** The slug part that names the mailbox ("correo-imsanti" -> "imsanti"), or null. */
function mailboxSlugSuffix(topic: RegistryTopic): string | null {
	const slug = slugify(topic.slug);
	const bySlug = slug.match(MAILBOX_PREFIX);
	if (bySlug) return slug.slice(bySlug[0].length) || null;
	if (MAILBOX_NAME.test(topic.name.trim())) {
		const rest = slugify(
			topic.name
				.trim()
				.replace(MAILBOX_NAME, "")
				.replace(/^[\s·:–—-]+/, ""),
		);
		return rest || null;
	}
	return null;
}

/** Whether a registry entry reads as a mailbox topic ("correo-…", "Correo · …"). */
export function looksLikeMailboxTopic(topic: RegistryTopic): boolean {
	return MAILBOX_PREFIX.test(slugify(topic.slug)) || MAILBOX_NAME.test(topic.name.trim());
}

/**
 * The names an address could go by in a slug: the local part, each non-TLD
 * domain label, the domain and the whole address, all slugified with "." -> "-".
 */
function addressKeys(address: string): Set<string> {
	const clean = address.trim().toLowerCase();
	const [local = "", domain = ""] = clean.split("@");
	const labels = domain.split(".").filter(Boolean);
	const keys = new Set<string>();
	const put = (value: string) => {
		const key = slugify(value.replace(/[.@]/g, " "));
		if (key) keys.add(key);
	};
	put(local);
	for (const label of labels.slice(0, -1)) put(label);
	put(domain);
	put(clean);
	return keys;
}

/** The mailbox-looking registry entry that names this address, if exactly one does. */
function registryEntryForAddress(
	address: string | null,
	registry: RegistryTopic[],
): RegistryTopic | null {
	if (!address) return null;
	const keys = addressKeys(address);
	const hits = registry.filter((topic) => {
		const suffix = mailboxSlugSuffix(topic);
		return suffix !== null && keys.has(suffix);
	});
	return hits.length === 1 ? (hits[0] ?? null) : null;
}

function label(mapping: TopicMapping): string {
	return mapping.address ?? `${mapping.mailboxId} (mailbox row missing)`;
}

export type CompareInput = {
	/** runtime_config `telegram.chat_id`; null when the bridge adopted no chat. */
	boundChatId: string | null;
	/** The registry's `chat_id` field: the HQ forum the thread ids belong to. */
	registryChatId: string;
	registry: RegistryTopic[];
	/** Every telegram_topics row, any chat. */
	mappings: TopicMapping[];
};

/**
 * One finding per D1 mapping of the bound chat, plus a warning per mailbox-like
 * registry entry that no mapping delivers to. When the bound chat is not the
 * registry's forum the comparison is meaningless (thread ids are per chat), so it
 * returns a single info line saying which chat is bound and whether rows for the
 * HQ forum are already staged for a rebind.
 */
export function compareTopicRegistry(input: CompareInput): TopicFinding[] {
	const registryChat = input.registryChatId.trim();
	const bound = input.boundChatId?.trim() || null;
	const forHq = input.mappings.filter((m) => m.chatId.trim() === registryChat);

	if (bound !== registryChat) {
		const staged =
			forHq.length > 0
				? `${forHq.length} D1 topic mapping(s) for the HQ forum exist (${forHq
						.map((m) => `${label(m)} -> ${m.topicId}`)
						.join(", ")}), but are unused until a rebind`
				: "no D1 topic mappings exist for the HQ forum yet";
		return [
			{
				status: "info",
				message: `${bound ? `Bound chat is ${bound}` : "No chat is bound"}, not the tg-hq forum ${registryChat}; ${staged}.`,
			},
		];
	}

	const findings: TopicFinding[] = [];
	const byId = new Map(input.registry.map((topic) => [topic.id, topic]));
	for (const mapping of [...forHq].sort((a, b) => a.topicId - b.topicId)) {
		const sameId = byId.get(mapping.topicId);
		if (sameId) {
			findings.push({
				status: "pass",
				message: `${label(mapping)} -> thread ${mapping.topicId} matches tg-hq "${sameId.slug}" («${sameId.name}»).`,
			});
			continue;
		}
		const named = registryEntryForAddress(mapping.address, input.registry);
		if (named) {
			findings.push({
				status: "warn",
				message: `${label(mapping)}: thread id mismatch — D1 delivers to thread ${mapping.topicId}, tg-hq "${named.slug}" («${named.name}») records thread ${named.id}.`,
				fix: "Find out which thread is real in the Telegram app (do not probe with editForumTopic: it renames), then fix the other side.",
			});
			continue;
		}
		findings.push({
			status: "warn",
			message: `${label(mapping)} -> thread ${mapping.topicId} is missing from the tg-hq registry.`,
			fix: `tg-hq adopt <slug> ${mapping.topicId} '<topic name>' (note: adopt renames the topic to the name you pass).`,
		});
	}

	const mappedIds = new Set(forHq.map((m) => m.topicId));
	for (const topic of input.registry) {
		if (!looksLikeMailboxTopic(topic) || mappedIds.has(topic.id)) continue;
		findings.push({
			status: "warn",
			message: `tg-hq "${topic.slug}" («${topic.name}», thread ${topic.id}) looks like a mailbox topic but no D1 mapping delivers to it.`,
			fix: "Map the mailbox to that thread in telegram_topics, or `tg-hq forget` the entry if the topic is not a mailbox's.",
		});
	}

	if (findings.length === 0) {
		findings.push({
			status: "info",
			message: `No D1 topic mappings for chat ${registryChat} and no mailbox topics in the tg-hq registry.`,
		});
	}
	return findings;
}