Diseño del código

Las capas del núcleo compartido y de las herramientas de mensajería, las reglas que las mantienen separadas, los patrones que usan y qué partes se pueden sustituir y a qué coste.

Esta página explica cómo está organizado el código de tg, max y las demás herramientas de WireCat: qué capa puede llamar a cuál, cómo un módulo habla con el resto a través de una interfaz y qué hay que cambiar para sustituir una parte. Léela antes de cambiar el núcleo compartido, de escribir un adaptador para un mensajero nuevo o de juzgar si un cambio encaja en el diseño. Arquitectura cuenta con más detalle los paquetes y el recorrido de un comando en ejecución; esta página trata de la estructura del código y de cómo se mantiene.

Dos términos aparecen en todo el texto. Un puerto es una interfaz que define el núcleo compartido y de la que depende. Un adaptador es el código de una herramienta que implementa un puerto para un mensajero o proveedor. El núcleo llama al puerto y nunca importa el adaptador.

La página describe el código leído el 14 de octubre de 2026, en cli-core a3b5b3c, max-cli 0536988 y tg-cli faea191. Los recuentos de archivos y líneas son de esos commits.

Paquetes

Quién depende de quién entre los paquetes

Las dependencias van en un solo sentido. cli-base no sabe nada de mensajería. cli-core contiene todo lo de mensajería que no pertenece a un solo mensajero. Cada herramienta fija una versión exacta de ambos, así que un cambio en el núcleo llega a los usuarios solo cuando una herramienta actualiza esa versión. Una herramienta que aún no lo ha hecho sigue funcionando con la versión fijada.

Capas

Una capa puede llamar a las capas inferiores y nunca a las superiores. El diagrama muestra cli-core y una herramienta; la flecha punteada es la implementación de un puerto, no una llamada.

Las capas de cli-core y cómo se conecta una herramienta
CapaSu funciónTamañoQué no puede importar
domain/Tipos independientes del mensajero: Chat, Message, Person, localizadores, tramos de formato. Ningún comportamiento que necesite red o archivos.24 archivosNinguna biblioteca de mensajero ni adaptador.
ports/messenger.tsEl contrato que implementa cada mensajero.1 archivo, 663 líneasLo mismo.
store/El archivo local: una interfaz MessageStore, abierta por openStore, sobre módulos SQLite, migraciones e índices de búsqueda.101 archivosNada fuera de store/ puede importar un módulo de store/sqlite/.
services/Casos de uso: mensajes, chats, personas, búsqueda, vectores, menciones, moderación.81 archivosCommander, un módulo de comando, un módulo SQLite.
sends/Toda escritura: la protección de envíos, los identificadores de operación, el registro de envíos, los adjuntos.14 archivosLo mismo que los servicios.
cli/, mcp/Presentación: comandos, el sobre JSON, configuración y perfiles; herramientas MCP sobre los mismos servicios.168 + 51 archivosUn módulo SQLite.
meetings/, tasks/Módulos autónomos con sus propios puertos.46 + 4 archivosCualquier otra parte de cli-core, una base de datos o el sistema de archivos.
readers/, images/Lectores de documentos y descriptores de imágenes, elegidos de un registro.13 + 6 archivosEl almacén, los servicios, los comandos y las herramientas MCP.

La última columna no es una recomendación. Cada fila es una regla de lint en el biome.json de cli-core, y una importación que la cruza hace fallar el pull request con una frase que nombra la regla.

Dentro de una herramienta

max no tiene biblioteca oficial, así que el cliente forma parte de la herramienta. Sus capas son una especificación del protocolo, un cliente generado a partir de ella, la conexión WebSocket, 22 fábricas de recursos en src/client/ y la raíz que las une en src/client.ts. max-adapter.ts traduce ese cliente al puerto. Las reglas de lint mantienen el protocolo, la especificación y el código generado detrás de src/client.ts, separan la parte de la Bot API oficial de la sesión de la cuenta personal e impiden que la sesión y max serve importen comandos.

Cómo se monta el cliente de max

tg usa mtcute. Solo src/telegram/ puede importarlo, y los comandos no pueden acceder directamente a su traducción de tipos ni a la de errores. El adaptador es una sola clase en telegram/adapter.ts.

Cómo habla un módulo con el resto

El contrato del mensajero

MessengerAdapter es un núcleo obligatorio más grupos opcionales. MessengerCore tiene siete métodos: self, me, resolve, chat, send, logout y close. Todo lo demás está en 39 grupos opcionales, como MessagePolls, ForumControl, ChatFolders y LiveUpdates.

El contrato del adaptador: un núcleo obligatorio y grupos opcionales

El código que necesita un método opcional lo pide por su nombre:

const react = capability(connection, "react", "react to a message")

capability() devuelve el método o falla con validation_error y el texto «this messenger cannot react to a message» si el adaptador no lo tiene. Una opción que el mensajero no puede cumplir se rechaza de la misma manera, nunca se descarta en silencio.

La descripción

Cada herramienta entrega también al núcleo un objeto Messenger de 47 campos: el nombre de la aplicación, cómo conectarse, cómo paginar el historial y opciones como pollQuiz, topicShow y stickers. Los comandos leen esas opciones al registrar sus parámetros, así que polls create --quiz solo existe en una herramienta cuyo mensajero tiene cuestionarios.

Los servicios y sus dependencias

Un servicio es una función que recibe un objeto de dependencias y devuelve el caso de uso: messagesService(deps), chatsService(deps). No hay clases ni contenedor. El objeto de dependencias, ServiceDeps, lleva la descripción del mensajero, la protección de envíos y funciones que abren recursos de forma perezosa:

connection: () => Promise<MessengerAdapter>
store: () => Promise<MessageStore>
account: () => Promise<AccountKey>

Nada se abre hasta que un servicio lo pide, así que una lectura que responde el almacén local nunca se conecta. servicesFor(deps) crea todos los servicios una vez; los comandos y las herramientas MCP los toman de ahí, así que dan la misma respuesta y el mismo error para la misma entrada.

Una herramienta puede sustituir un servicio sin copiar el núcleo. Messenger.services recibe los servicios compartidos y devuelve sustitutos; max lo usa para que la moderación encuentre los chats a su manera.

Módulos autónomos

meetings/ y tasks/ definen sus propios puertos: un proveedor implementa MeetingSource en su propio paquete, y el almacén implementa MeetingStore y TaskStore. El resto de cli-core los importa solo a través de su index.ts, y ellos no importan nada más de cli-core. Un proveedor de reuniones como Zoom se conecta sin que el núcleo sepa que existe.

Registros

Los lectores de documentos y los descriptores de imágenes se registran con registerReader y registerImageProvider, con una posición opcional. Añadir un motor de PDF o un modelo de imágenes consiste en registrarlo; el código que llama a los lectores no cambia.

Una escritura de principio a fin

max messages send <chat> --file report.pdf. El archivo se comprueba al empezar el comando y solo se lee mientras se sube, así que un archivo grande nunca se guarda entero en memoria.

Un envío con archivo, del comando al mensajero

En tg el adaptador entrega el mismo Blob a mtcute y vuelve a abrir el archivo en cada reintento. Un adaptador cuya biblioteca necesita los bytes en memoria llama a uploadBytes, que rechaza un archivo de más de 256 MiB.

Patrones en uso

PatrónDóndeQué aporta
Puertos y adaptadoresports/messenger.ts, meetings/source.ts, tasks/store.tsEl núcleo nunca importa un proveedor; un proveedor puede cambiar sin tocar el núcleo.
Grupos de capacidadesMessengerAdapter, capability()Un mensajero implementa lo que tiene; una función que falta es un rechazo claro, no un fallo.
Opciones de la descripciónMessengerLos parámetros aparecen solo donde funcionan.
Funciones fábrica sobre un objeto de dependenciasxService(deps), ServiceDepsFunciones simples, fáciles de probar con un sustituto; la apertura perezosa abarata los comandos de una sola ejecución.
Raíces de composiciónservicesFor, MaxClient, el contexto del comandoUn solo lugar construye el grafo de objetos y cierra lo que abrió.
Punto de sustituciónMessenger.servicesUna herramienta cambia un caso de uso a la vez para sus comandos y sus herramientas MCP.
Traducción protectoramax-adapter.ts, telegram/map.tsLos tipos y errores del proveedor se convierten en tipos del dominio y códigos de error tipados en un solo archivo por herramienta.
Proxy envolventethroughWrapper en ports/messenger.tsEl registro de la ejecución y la caché envuelven el adaptador; un método nuevo del adaptador pasa sin tocar los envoltorios.
Registrosreaders/registry.ts, images/registry.tsLos motores nuevos se conectan sin cambiar a quienes los llaman.
Cliente generadomax: de spec/ a generated/Las operaciones de MAX se declaran una vez; una prueba falla si los archivos generados se desvían.

Qué se puede sustituir y a qué coste

Una parte se puede sustituir cuando el código que está por encima depende de una interfaz y no de la parte, y una regla de lint o una prueba lo mantiene así.

ParteCosteCómo
La biblioteca de TelegramAcotadoReescribir src/telegram/; nada más importa mtcute.
El protocolo de MAXAcotadoCambiar la especificación y regenerar; solo el cliente llega al protocolo.
Un caso de uso en una herramientaPequeñoMessenger.services.
Lectores de documentos, descriptores de imágenes, modelos de vectoresPequeñoRegistrar un lector o proveedor; elegir un modelo local o remoto.
Un proveedor de reunionesPequeñoImplementar MeetingSource en el paquete del proveedor.
Un mensajero nuevoModeradoImplementar MessengerCore y los grupos que tenga el mensajero, y escribir una descripción. Los comandos, las herramientas MCP, el almacén, la búsqueda y la protección de envíos vienen con el núcleo.
Una interfaz nueva junto a la CLI y MCPModeradoLlamar a servicesFor(deps) y mostrar la respuesta. Los servicios aún importan algunas utilidades de cli/ (configuración, utilidades de errores, consultas de listas), que tendrían que moverse con ella.
El motor de almacenamientoDifícilReimplementar MessageStore: unos 140 métodos, incluidas la búsqueda de texto completo, las raíces de palabras y la búsqueda por vectores. El límite se respeta, pero la interfaz es una sola superficie amplia.

Cómo se mantiene el diseño

La mayor parte del diseño la comprueban máquinas, así que un cambio que lo rompe falla antes de la revisión.

ComprobaciónQué detieneDónde
Importaciones restringidasUna importación que cruza un límite de capa de la tabla anterior o los límites de una herramienta.El biome.json de cada repositorio, ejecutado por pnpm lint.
Ciclos de importaciónDos módulos que se importan entre sí en ejecución, directamente o a través de otros. Las importaciones solo de tipos están permitidas.noImportCycles en las reglas de nivel superior del lint de cli-core.
ParityUn cambio del núcleo que rompe la compilación de cualquiera de las herramientas. Una rama con el mismo nombre en una herramienta permite que un cambio incompatible pase las comprobaciones antes de que las herramientas lo adopten.El workflow Parity de cli-core.
Contrato de comandosLa forma de commands --json, los efectos declarados en cada comando, la captura de la ayuda.contracts:check y cli-dev lint de cada herramienta.
Comprobación de tiposCódigo, pruebas y scripts como tres proyectos.pnpm typecheck.
Archivos generadosEdiciones manuales de las páginas de comandos generadas o del cliente del protocolo de max.Pruebas que comparan con pnpm generate.

Biome construye el grafo de importaciones para noImportCycles solo cuando la regla está activada en las reglas de nivel superior. Dentro de una entrada overrides no informa de nada, así que la regla se queda arriba.

Convenciones para el código nuevo:

  1. Una función nueva del mensajero es un grupo opcional en ports/messenger.ts, al que se accede con capability() y que se ofrece mediante una opción de la descripción. MessengerCore no crece.
  2. Un caso de uso nuevo es un servicio que recibe ServiceDeps. Lo llaman el comando y la herramienta MCP; un archivo de comando no contiene lógica de negocio.
  3. Una consulta nueva al almacén es un módulo en store/sqlite/, expuesto solo a través de store.ts. Un servicio lee con accountStore(deps); una lectura deliberada de varias cuentas se identifica con crossAccount(deps, reason).
  4. Toda escritura pasa por la protección de envíos: comprobada antes, registrada después, con un identificador de operación.
  5. Un tipo del proveedor no pasa del adaptador.
  6. Un cambio incompatible del núcleo aparece en su registro de cambios en «Changed — may break callers», con ramas del mismo nombre en las herramientas para que Parity compile su adopción.
  7. Dividir un archivo requiere una costura: una dependencia que elimina, un ciclo que rompe o una parte que se puede probar o sustituir por separado. Su longitud por sí sola no es motivo.

Límites del diseño actual

  • El almacén es una sola interfaz amplia. MessageStore tiene unos 140 métodos sobre un único openStore, y store.ts tiene 2042 líneas. Casi todas las funciones lo tocan. Dividirlo solo sirve siguiendo una costura real: recursos que se abren y cierran por separado.
  • Los servicios aún saben un poco de la CLI. Importan la descripción Messenger como tipo y algunas utilidades de cli/ en ejecución.
  • El adaptador de tg es una clase grande: 2312 líneas y unos 110 métodos, legible método a método, pero es el lugar donde acaba cada función de Telegram.
  • client/values.ts de max tiene 59 exportaciones que usan 20 módulos.
  • Solo cli-core aplica la regla contra ciclos de importación. tg y max no tienen ciclos hoy, pero su lint no impide uno nuevo.

Para ver dónde encajan en estas capas la búsqueda, los vectores y el grafo de conversaciones, sigue con la arquitectura de búsqueda.