{"repo": "alxbryann/pemie.ai", "files": {"apps/api/src/services/channels.ts": {"1a7611d19def63b2b535c799d40dfe5058d69d17": {"t": 1785747439.320907, "n": 21549, "h": "0a6e8255b84e8478", "c": "// Canal Telegram: vincular cuenta, BYOK Anthropic, user MCP key.\n// El bot no guarda la MCP key en claro: usa el registro ApiKey vía invokeMcpTool.\n\nimport { randomBytes } from \"node:crypto\";\nimport {\n API_SCOPES,\n CHANNEL_LLM_PROVIDERS,\n CHANNEL_LLM_DEFAULT_MODELS,\n isAllowedModel,\n listModelsForProvider,\n type ChannelLlmProvider,\n} from \"@pemie/shared\";\nimport { Prisma } from \"@prisma/client\";\nimport { prisma } from \"../db.js\";\nimport { env } from \"../env.js\";\nimport { encryptSecret } from \"../lib/secrets.js\";\nimport { badRequest, forbidden, notFound, ServiceError } from \"./errors.js\";\nimport * as agents from \"./agents.js\";\nimport { trackServerEvent } from \"./analytics.js\";\n\nconst LINK_TTL_MS = 15 * 60 * 1000;\nconst PROVIDER = \"telegram\";\n\nconst RATE_WINDOW_MS = 60_000;\nconst RATE_MAX = 20;\n/** Retención de updates vistos: cubre de sobra la ventana de reintentos. */\nconst UPDATE_RETENTION_MS = 24 * 60 * 60 * 1000;\n\n/** Ventana de historial enviada al LLM (user + assistant). */\nexport const HISTORY_KEEP = 10;\nconst MESSAGE_CONTENT_MAX = 4_000;\nconst SUMMARY_MAX = 1_500;\n\nfunction botUsernameFromToken(_token: string | undefined): string | null {\n return env.TELEGRAM_BOT_USERNAME?.trim() || null;\n}\n\nfunction parseLlmProvider(raw: string | undefined | null): ChannelLlmProvider {\n const p = (raw ?? \"anthropic\") as ChannelLlmProvider;\n if (!(CHANNEL_LLM_PROVIDERS as readonly string[]).includes(p))\n throw badRequest(`Proveedor LLM inválido: ${raw}`, \"invalid_llm_provider\");\n return p;\n}\n\nfunction validateLlmKey(provider: ChannelLlmProvider, key: string) {\n if (key.length < 20) throw badRequest(\"La API key es demasiado corta\", \"invalid_llm_key\");\n if (provider === \"anthropic\") {\n if (!key.startsWith(\"sk-ant-\") && !key.startsWith(\"sk-\"))\n throw badRequest(\"La key de Anthropic no parece válida (sk-ant-…)\", \"invalid_llm_key\");\n } else if (provider === \"openai\") {\n if (!key.startsWith(\"sk-\"))\n throw badRequest(\"La key de OpenAI no parece válida (sk-…)\", \"invalid_llm_key\");\n }\n // deepseek: acepta sk-… u otros prefijos comerciales\n}\n\n/** Estado del canal Telegram del usuario (sin secretos). */\nexport async function getChannelStatus(userId: string) {\n const [link, config, credentials] = await Promise.all([\n prisma.channelLink.findUnique({ where: { userId_provider: { userId, provider: PROVIDER } } }),\n prisma.userChannelConfig.findUnique({\n where: { userId },\n include: { apiKey: true, defaultProject: { select: { id: true, name: true, slug: true } } },\n }),\n prisma.channelLlmCredential.findMany({ where: { userId } }),\n ]);\n\n const botConfigured = Boolean(env.TELEGRAM_BOT_TOKEN?.trim());\n const botUsername = botUsernameFromToken(env.TELEGRAM_BOT_TOKEN);\n const llmProvider = (CHANNEL_LLM_PROVIDERS as readonly string[]).includes(config?.llmProvider ?? \"\")\n ? (config!.llmProvider as ChannelLlmProvider)\n : \"anthropic\";\n\n const providers = Object.fromEntries(\n CHANNEL_LLM_PROVIDERS.map((p) => {\n const cred = credentials.find((c) => c.provider === p);\n const legacy =\n !cred && config?.llmProvider === p && config.llmKeyCiphertext\n ? { last4: config.llmKeyLast4 }\n : null;\n return [\n p,\n {\n hasKey: Boolean(cred || legacy),\n last4: cred?.llmKeyLast4 ?? legacy?.last4 ?? null,\n models: [...listModelsForProvider(p)],\n },\n ];\n })\n ) as Record;\n\n const activeHasKey = providers[llmProvider].hasKey;\n\n return {\n botConfigured,\n botUsername,\n linked: Boolean(link),\n telegramUsername: link?.username ?? null,\n linkedAt: link?.linkedAt ?? null,\n enabled: config?.enabled ?? false,\n hasLlmKey: activeHasKey,\n llmKeyLast4: providers[llmProvider].last4,\n llmProvider,\n model: config?.model ?? CHANNEL_LLM_DEFAULT_MODELS[llmProvider],\n models: [...listModelsForProvider(llmProvider)],\n providers,\n defaultProject: config?.defaultProject ?? null,\n apiKeyPrefix: config?.apiKey?.prefix ?? null,\n ready: Boolean(link && config?.enabled && activeHasKey),\n };\n}\n\n/**\n * Crea un token one-shot para deep link t.me/Bot?start=.\n * Al completar el link se asegura UserChannelConfig + user MCP key.\n *\n * `analyticsEnabled` viene explícito del `User` que rest/channels.ts ya cargó\n * para autorizar la petición (nunca un fetch aparte); si es `false`,\n * trackServerEvent es no-op silencioso.\n */\nexport async function createLinkToken(\n userId: string,\n analyticsEnabled: boolean,\n projectId?: string | null\n) {\n if (!env.TELEGRAM_BOT_TOKEN?.trim()) {\n trackServerEvent(analyticsEnabled, userId, \"telegram_link_started_failed\", {\n reason: \"telegram_not_configured\",\n });\n throw badRequest(\"Telegram no está configurado en el servidor\", \"telegram_not_configured\");\n }\n\n if (projectId) {\n const project = await prisma.project.findUnique({ where: { id: projectId } });\n if (!project) throw notFound(\"Proyecto no encontrado\");\n const membership = await prisma.membership.findUnique({\n where: { userId_workspaceId: { workspaceId: project.workspaceId, userId } },\n });\n if (!membership) throw forbidden(\"No eres miembro del workspace del proyecto\");\n }\n\n const token = randomBytes(24).toString(\"hex\");\n const row = await prisma.channelLinkToken.create({\n data: {\n token,\n userId,\n projectId: projectId ?? null,\n expiresAt: new Date(Date.now() + LINK_TTL_MS),\n },\n });\n\n const botUsername = botUsernameFromToken(env.TELEGRAM_BOT_TOKEN);\n const deepLink = botUsername\n ? `https://t.me/${botUsername}?start=${token}`\n : null;\n\n trackServerEvent(analyticsEnabled, userId, \"telegram_link_started\");\n\n return {\n token: row.token,\n expiresAt: row.expiresAt,\n deepLink,\n // Fallback si no hay username: el usuario pega /start en el bot.\n startPayload: token,\n };\n}\n\n/**\n * Completa el vínculo desde el webhook /start .\n * Crea o reutiliza UserChannelConfig + ApiKey user-scoped.\n */\nexport async function completeLinkFromToken(\n token: string,\n telegramUserId: string,\n telegramUsername?: string | null\n) {\n // El link se completa fuera de la SPA (el bot habla directo con Telegram): se\n // detecta acá, al procesar /start, y se incluye el User ya en la misma query\n // para trackear `telegram_linked` sin un fetch aparte.\n const row = await prisma.channelLinkToken.findUnique({\n where: { token },\n include: { user: { select: { analyticsEnabled: true } } },\n });\n if (!row || row.usedAt) throw badRequest(\"Token de vínculo inválido o ya usado\", \"invalid_link_token\");\n if (row.expiresAt.getTime() < Date.now())\n throw badRequest(\"Token de vínculo expirado; genera uno nuevo desde Pemie\", \"link_token_expired\");\n\n const userId = row.userId;\n\n // Si otro usuario Pemie ya tiene este telegram, o este user tiene otro telegram: reemplazar.\n await prisma.$transaction(async (tx) => {\n await tx.channelLink.deleteMany({\n where: {\n OR: [\n { provider: PROVIDER, externalId: telegramUserId },\n { userId, provider: PROVIDER },\n ],\n },\n });\n await tx.channelLink.create({\n data: {\n userId,\n provider: PROVIDER,\n externalId: telegramUserId,\n username: telegramUsername ?? null,\n },\n });\n await tx.channelLinkToken.update({\n where: { id: row.id },\n data: { usedAt: new Date() },\n });\n });\n\n await ensureUserChannelConfig(userId, row.projectId);\n trackServerEvent(row.user.analyticsEnabled, userId, \"telegram_linked\");\n return { userId, projectId: row.projectId };\n}\n\n/** Asegura config + user MCP key (reutiliza si ya existe). */\nexport async function ensureUserChannelConfig(userId: string, defaultProjectId?: string | null) {\n const existing = await prisma.userChannelConfig.findUnique({ where: { userId } });\n if (existing) {\n if (defaultProjectId && !existing.defaultProjectId) {\n return prisma.userChannelConfig.update({\n where: { userId },\n data: { defaultProjectId, enabled: true },\n });\n }\n if (!existing.enabled) {\n return prisma.userChannelConfig.update({\n where: { userId },\n data: { enabled: true },\n });\n }\n return existing;\n }\n\n // Home workspace: del default project, o el primero donde sea member.\n let workspaceId: string | null = null;\n if (defaultProjectId) {\n const p = await prisma.project.findUnique({ where: { id: defaultProjectId } });\n workspaceId = p?.workspaceId ?? null;\n }\n if (!workspaceId) {\n const m = await prisma.membership.findFirst({\n where: { userId },\n orderBy: { createdAt: \"asc\" },\n });\n workspaceId = m?.workspaceId ?? null;\n }\n if (!workspaceId) throw badRequest(\"Necesitas un workspace para conectar Telegram\", \"no_workspace\");\n\n const { apiKey } = await agents.createApiKey(userId, workspaceId, {\n name: \"Telegram bot\",\n scopeLevel: \"user\",\n scopes: [...API_SCOPES],\n skipAdminCheck: true,\n });\n\n // createApiKey returns public view; we need the id\n return prisma.userChannelConfig.create({\n data: {\n userId,\n apiKeyId: apiKey.id,\n defaultProjectId: defaultProjectId ?? null,\n enabled: true,\n },\n });\n}\n\n/** Guarda / actualiza la LLM key del usuario (cifrada) y el proveedor activo. */\nexport async function setLlmKey(\n userId: string,\n analyticsEnabled: boolean,\n rawKey: string,\n opts?: { provider?: string; model?: string }\n) {\n try {\n const provider = parseLlmProvider(opts?.provider ?? \"anthropic\");\n const trimmed = rawKey.trim();\n validateLlmKey(provider, trimmed);\n\n await ensureUserChannelConfig(userId);\n const ciphertext = encryptSecret(trimmed);\n const last4 = trimmed.slice(-4);\n const requested = opts?.model?.trim();\n // Un modelo fuera del catálogo se rechaza igual que en `setChannelModel`: caer\n // al default en silencio dejaba al usuario creyendo que guardó otra cosa.\n if (requested && !isAllowedModel(provider, requested)) {\n throw badRequest(\n `Modelo inválido para ${provider}. Usa: ${listModelsForProvider(provider).join(\", \")}`,\n \"invalid_model\"\n );\n }\n const model = requested || CHANNEL_LLM_DEFAULT_MODELS[provider];\n\n await prisma.channelLlmCredential.upsert({\n where: { userId_provider: { userId, provider } },\n create: { userId, provider, llmKeyCiphertext: ciphertext, llmKeyLast4: last4 },\n update: { llmKeyCiphertext: ciphertext, llmKeyLast4: last4 },\n });\n\n const config = await prisma.userChannelConfig.update({\n where: { userId },\n data: {\n llmProvider: provider,\n llmKeyCiphertext: ciphertext,\n llmKeyLast4: last4,\n model,\n enabled: true,\n },\n });\n // provider/model son metadata, no secretos: nunca el valor de la key.\n trackServerEvent(analyticsEnabled, userId, \"telegram_llm_key_set\", { provider, model });\n return config;\n } catch (err) {\n const reason = err instanceof ServiceError ? err.code ?? \"unknown_error\" : \"unknown_error\";\n trackServerEvent(analyticsEnabled, userId, \"telegram_llm_key_set_failed\", { reason });\n throw err;\n }\n}\n\n/**\n * Borra la LLM key guardada de un proveedor.\n *\n * La key es del usuario y se factura a su cuenta: tiene que poder revocarla desde\n * el producto. Si además es la del proveedor activo se limpia el espejo en\n * `UserChannelConfig`, que es de donde leen `loadBotSession` y `getChannelStatus`.\n */\nexport async function deleteLlmKey(userId: string, rawProvider: string) {\n const provider = parseLlmProvider(rawProvider);\n\n await prisma.channelLlmCredential.deleteMany({ where: { userId, provider } });\n await prisma.userChannelConfig.updateMany({\n where: { userId, llmProvider: provider },\n data: { llmKeyCiphertext: null, llmKeyLast4: null },\n });\n\n return prisma.userChannelConfig.findUnique({ where: { userId } });\n}\n\n/** Cambia el proveedor activo si ya hay credential guardada. */\nexport async function setChannelProvider(userId: string, rawProvider: string) {\n const provider = parseLlmProvider(rawProvider);\n await ensureUserChannelConfig(userId);\n\n const cred = await prisma.channelLlmCredential.findUnique({\n where: { userId_provider: { userId, provider } },\n });\n if (!cred) {\n // Compat: key solo en UserChannelConfig del mismo proveedor\n const config = await prisma.userChannelConfig.findUnique({ where: { userId } });\n if (config?.llmProvider === provider && config.llmKeyCiphertext) {\n await prisma.channelLlmCredential.create({\n data: {\n userId,\n provider,\n llmKeyCiphertext: config.llmKeyCiphertext,\n llmKeyLast4: config.llmKeyLast4 ?? \"????\",\n },\n });\n } else {\n throw badRequest(\n `No hay API key guardada para ${provider}. Pégala en Pemie → Agente → Telegram.`,\n \"provider_key_missing\"\n );\n }\n }\n\n const fresh = await prisma.channelLlmCredential.findUniqueOrThrow({\n where: { userId_provider: { userId, provider } },\n });\n const config = await prisma.userChannelConfig.findUniqueOrThrow({ where: { userId } });\n const model = isAllowedModel(provider, config.model)\n ? config.model\n : CHANNEL_LLM_DEFAULT_MODELS[provider];\n\n return prisma.userChannelConfig.update({\n where: { userId },\n data: {\n llmProvider: provider,\n llmKeyCiphertext: fresh.llmKeyCiphertext,\n llmKeyLast4: fresh.llmKeyLast4,\n model,\n enabled: true,\n },\n });\n}\n\n/** Cambia el modelo del proveedor activo (catálogo fijo). */\nexport async function setChannelModel(userId: string, rawModel: string) {\n await ensureUserChannelConfig(userId);\n const config = await prisma.userChannelConfig.findUniqueOrThrow({ where: { userId } });\n const provider = parseLlmProvider(config.llmProvider);\n const model = rawModel.trim();\n if (!isAllowedModel(provider, model)) {\n throw badRequest(\n `Modelo inválido para ${provider}. Usa: ${listModelsForProvider(provider).join(\", \")}`,\n \"invalid_model\"\n );\n }\n return prisma.userChannelConfig.update({\n where: { userId },\n data: { model },\n });\n}\n\nfunction truncateContent(text: string, max = MESSAGE_CONTENT_MAX): string {\n if (text.length <= max) return text;\n return text.slice(0, max - 1) + \"…\";\n}\n\nexport async function listRecentChannelMessages(userId: string, limit = HISTORY_KEEP) {\n // Ordena por `seq`, no por `createdAt`: el par user+assistant se inserta en la\n // misma transacción y comparte timestamp, así que el desempate sería arbitrario\n // y podría abrir el historial con un turno \"assistant\" (400 del proveedor).\n const rows = await prisma.channelMessage.findMany({\n where: { userId, provider: PROVIDER },\n orderBy: { seq: \"desc\" },\n take: limit,\n select: { role: true, content: true, createdAt: true },\n });\n return rows.reverse();\n}\n\nexport async function getConversationSummary(userId: string): Promise {\n const config = await prisma.userChannelConfig.findUnique({\n where: { userId },\n select: { conversationSummary: true },\n });\n return config?.conversationSummary ?? null;\n}\n\nexport async function setConversationSummary(userId: string, summary: string | null) {\n const value = summary ? truncateContent(summary, SUMMARY_MAX) : null;\n await prisma.userChannelConfig.update({\n where: { userId },\n data: { conversationSummary: value },\n });\n}\n\n/**\n * Guarda el par user/assistant del turno, poda a HISTORY_KEEP y\n * devuelve los mensajes expulsados (para actualizar el resumen rolling).\n */\nexport async function recordTurnMessages(\n userId: string,\n userText: string,\n assistantText: string\n): Promise> {\n await prisma.channelMessage.createMany({\n data: [\n { userId, provider: PROVIDER, role: \"user\", content: truncateContent(userText) },\n { userId, provider: PROVIDER, role: \"assistant\", content: truncateContent(assistantText) },\n ],\n });\n\n // Mismo motivo que en `listRecentChannelMessages`: por `createdAt` la poda podría\n // expulsar el assistant y dejar huérfano su user del mismo turno.\n const all = await prisma.channelMessage.findMany({\n where: { userId, provider: PROVIDER },\n orderBy: { seq: \"asc\" },\n select: { id: true, role: true, content: true },\n });\n\n if (all.length <= HISTORY_KEEP) return [];\n\n const overflow = all.slice(0, all.length - HISTORY_KEEP);\n await prisma.channelMessage.deleteMany({\n where: { id: { in: overflow.map((m) => m.id) } },\n });\n return overflow.map((m) => ({ role: m.role, content: m.content }));\n}\n\nexport async function clearChannelMessages(userId: string) {\n await prisma.channelMessage.deleteMany({ where: { userId, provider: PROVIDER } });\n await prisma.userChannelConfig.updateMany({\n where: { userId },\n data: { conversationSummary: null },\n });\n}\n\nexport async function setDefaultProject(\n userId: string,\n analyticsEnabled: boolean,\n projectId: string | null\n) {\n await ensureUserChannelConfig(userId);\n if (projectId) {\n const project = await prisma.project.findUnique({ where: { id: projectId } });\n if (!project) throw notFound(\"Proyecto no encontrado\");\n const membership = await prisma.membership.findUnique({\n where: { userId_workspaceId: { workspaceId: project.workspaceId, userId } },\n });\n if (!membership) throw forbidden(\"No eres miembro del workspace del proyecto\");\n }\n const config = await prisma.userChannelConfig.update({\n where: { userId },\n data: { defaultProjectId: projectId },\n });\n trackServerEvent(analyticsEnabled, userId, \"telegram_default_project_set\");\n return config;\n}\n\n/** Desvincula Telegram y desactiva el canal (no borra la user key MCP ni credentials). */\nexport async function disconnectChannel(userId: string, analyticsEnabled: boolean) {\n await prisma.channelLink.deleteMany({ where: { userId, provider: PROVIDER } });\n await clearChannelMessages(userId).catch(() => {});\n const config = await prisma.userChannelConfig.findUnique({ where: { userId } });\n if (config) {\n await prisma.userChannelConfig.update({\n where: { userId },\n data: { enabled: false },\n });\n }\n trackServerEvent(analyticsEnabled, userId, \"telegram_disconnected\");\n return { ok: true };\n}\n\nexport type UpdateClaim = \"claimed\" | \"duplicate\" | \"rate_limited\";\n\n/**\n * Reserva un update entrante antes de procesarlo.\n *\n * Telegram reintenta la entrega cuando el webhook tarda o responde 5xx, y el\n * turno puede ejecutar tools de escritura (publicar informe, crear tarjeta):\n * repetirlo duplica efectos. La unique (provider, updateId) hace de candado, así\n * que el reintento cae en `duplicate` y no vuelve a ejecutar nada. Si el turno\n * se cae a mitad el update queda reservado sin `processedAt` — preferimos perder\n * una respuesta antes que duplicar escrituras.\n *\n * Las filas también son el rate limit: contar en DB funciona en serverless,\n * donde cada request puede caer en otra instancia y un Map de proceso no vale.\n */\nexport async function claimChannelUpdate(\n updateId: string,\n externalId: string\n): Promise {\n try {\n await prisma.channelUpdate.create({\n data: { provider: PROVIDER, updateId, externalId },\n });\n } catch (err) {\n if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === \"P2002\")\n return \"duplicate\";\n throw err;\n }\n\n const recent = await prisma.channelUpdate.count({\n where: {\n provider: PROVIDER,\n externalId,\n createdAt: { gte: new Date(Date.now() - RATE_WINDOW_MS) },\n },\n });\n\n // Poda oportunista: no hay cron, y la tabla no puede crecer sin techo.\n await prisma.channelUpdate\n .deleteMany({ where: { createdAt: { lt: new Date(Date.now() - UPDATE_RETENTION_MS) } } })\n .catch(() => {});\n\n return recent > RATE_MAX ? \"rate_limited\" : \"claimed\";\n}\n\n/** Cierra un update reservado (deja rastro de los turnos que sí terminaron). */\nexport async function markChannelUpdateProcessed(updateId: string) {\n await prisma.channelUpdate\n .updateMany({\n where: { provider: PROVIDER, updateId, processedAt: null },\n data: { processedAt: new Date() },\n })\n .catch(() => {});\n}\n\n/** Carga contexto completo del bot para un telegram user id. */\nexport async function loadBotSession(telegramUserId: string) {\n // Incluye analyticsEnabled en la misma query (no un fetch aparte): los comandos\n // del bot que trackean (/proyecto, /desvincular) lo leen de acá.\n const link = await prisma.channelLink.findUnique({\n where: { provider_externalId: { provider: PROVIDER, externalId: telegramUserId } },\n include: { user: { select: { analyticsEnabled: true } } },\n });\n if (!link) return null;\n\n const config = await prisma.userChannelConfig.findUnique({\n where: { userId: link.userId },\n include: {\n apiKey: true,\n defaultProject: { select: { id: true, name: true, slug: true, workspaceId: true } },\n },\n });\n if (!config || !config.enabled) return null;\n\n // Preferir credential del proveedor activo; fallback al espejo en config.\n const cred = await prisma.channelLlmCredential.findUnique({\n where: { userId_provider: { userId: link.userId, provider: config.llmProvider } },\n });\n const llmKeyCiphertext = cred?.llmKeyCiphertext ?? config.llmKeyCiphertext;\n const llmKeyLast4 = cred?.llmKeyLast4 ?? config.llmKeyLast4;\n\n return {\n link,\n config: {\n ...config,\n llmKeyCiphertext,\n llmKeyLast4,\n },\n };\n}\n"}, "aab25e87a1ac906dbf3bf5050aaeb3961e2207b8": {"t": 1785773327.5616632, "n": 21549, "h": "0a6e8255b84e8478", "c": "// Canal Telegram: vincular cuenta, BYOK Anthropic, user MCP key.\n// El bot no guarda la MCP key en claro: usa el registro ApiKey vía invokeMcpTool.\n\nimport { randomBytes } from \"node:crypto\";\nimport {\n API_SCOPES,\n CHANNEL_LLM_PROVIDERS,\n CHANNEL_LLM_DEFAULT_MODELS,\n isAllowedModel,\n listModelsForProvider,\n type ChannelLlmProvider,\n} from \"@pemie/shared\";\nimport { Prisma } from \"@prisma/client\";\nimport { prisma } from \"../db.js\";\nimport { env } from \"../env.js\";\nimport { encryptSecret } from \"../lib/secrets.js\";\nimport { badRequest, forbidden, notFound, ServiceError } from \"./errors.js\";\nimport * as agents from \"./agents.js\";\nimport { trackServerEvent } from \"./analytics.js\";\n\nconst LINK_TTL_MS = 15 * 60 * 1000;\nconst PROVIDER = \"telegram\";\n\nconst RATE_WINDOW_MS = 60_000;\nconst RATE_MAX = 20;\n/** Retención de updates vistos: cubre de sobra la ventana de reintentos. */\nconst UPDATE_RETENTION_MS = 24 * 60 * 60 * 1000;\n\n/** Ventana de historial enviada al LLM (user + assistant). */\nexport const HISTORY_KEEP = 10;\nconst MESSAGE_CONTENT_MAX = 4_000;\nconst SUMMARY_MAX = 1_500;\n\nfunction botUsernameFromToken(_token: string | undefined): string | null {\n return env.TELEGRAM_BOT_USERNAME?.trim() || null;\n}\n\nfunction parseLlmProvider(raw: string | undefined | null): ChannelLlmProvider {\n const p = (raw ?? \"anthropic\") as ChannelLlmProvider;\n if (!(CHANNEL_LLM_PROVIDERS as readonly string[]).includes(p))\n throw badRequest(`Proveedor LLM inválido: ${raw}`, \"invalid_llm_provider\");\n return p;\n}\n\nfunction validateLlmKey(provider: ChannelLlmProvider, key: string) {\n if (key.length < 20) throw badRequest(\"La API key es demasiado corta\", \"invalid_llm_key\");\n if (provider === \"anthropic\") {\n if (!key.startsWith(\"sk-ant-\") && !key.startsWith(\"sk-\"))\n throw badRequest(\"La key de Anthropic no parece válida (sk-ant-…)\", \"invalid_llm_key\");\n } else if (provider === \"openai\") {\n if (!key.startsWith(\"sk-\"))\n throw badRequest(\"La key de OpenAI no parece válida (sk-…)\", \"invalid_llm_key\");\n }\n // deepseek: acepta sk-… u otros prefijos comerciales\n}\n\n/** Estado del canal Telegram del usuario (sin secretos). */\nexport async function getChannelStatus(userId: string) {\n const [link, config, credentials] = await Promise.all([\n prisma.channelLink.findUnique({ where: { userId_provider: { userId, provider: PROVIDER } } }),\n prisma.userChannelConfig.findUnique({\n where: { userId },\n include: { apiKey: true, defaultProject: { select: { id: true, name: true, slug: true } } },\n }),\n prisma.channelLlmCredential.findMany({ where: { userId } }),\n ]);\n\n const botConfigured = Boolean(env.TELEGRAM_BOT_TOKEN?.trim());\n const botUsername = botUsernameFromToken(env.TELEGRAM_BOT_TOKEN);\n const llmProvider = (CHANNEL_LLM_PROVIDERS as readonly string[]).includes(config?.llmProvider ?? \"\")\n ? (config!.llmProvider as ChannelLlmProvider)\n : \"anthropic\";\n\n const providers = Object.fromEntries(\n CHANNEL_LLM_PROVIDERS.map((p) => {\n const cred = credentials.find((c) => c.provider === p);\n const legacy =\n !cred && config?.llmProvider === p && config.llmKeyCiphertext\n ? { last4: config.llmKeyLast4 }\n : null;\n return [\n p,\n {\n hasKey: Boolean(cred || legacy),\n last4: cred?.llmKeyLast4 ?? legacy?.last4 ?? null,\n models: [...listModelsForProvider(p)],\n },\n ];\n })\n ) as Record;\n\n const activeHasKey = providers[llmProvider].hasKey;\n\n return {\n botConfigured,\n botUsername,\n linked: Boolean(link),\n telegramUsername: link?.username ?? null,\n linkedAt: link?.linkedAt ?? null,\n enabled: config?.enabled ?? false,\n hasLlmKey: activeHasKey,\n llmKeyLast4: providers[llmProvider].last4,\n llmProvider,\n model: config?.model ?? CHANNEL_LLM_DEFAULT_MODELS[llmProvider],\n models: [...listModelsForProvider(llmProvider)],\n providers,\n defaultProject: config?.defaultProject ?? null,\n apiKeyPrefix: config?.apiKey?.prefix ?? null,\n ready: Boolean(link && config?.enabled && activeHasKey),\n };\n}\n\n/**\n * Crea un token one-shot para deep link t.me/Bot?start=.\n * Al completar el link se asegura UserChannelConfig + user MCP key.\n *\n * `analyticsEnabled` viene explícito del `User` que rest/channels.ts ya cargó\n * para autorizar la petición (nunca un fetch aparte); si es `false`,\n * trackServerEvent es no-op silencioso.\n */\nexport async function createLinkToken(\n userId: string,\n analyticsEnabled: boolean,\n projectId?: string | null\n) {\n if (!env.TELEGRAM_BOT_TOKEN?.trim()) {\n trackServerEvent(analyticsEnabled, userId, \"telegram_link_started_failed\", {\n reason: \"telegram_not_configured\",\n });\n throw badRequest(\"Telegram no está configurado en el servidor\", \"telegram_not_configured\");\n }\n\n if (projectId) {\n const project = await prisma.project.findUnique({ where: { id: projectId } });\n if (!project) throw notFound(\"Proyecto no encontrado\");\n const membership = await prisma.membership.findUnique({\n where: { userId_workspaceId: { workspaceId: project.workspaceId, userId } },\n });\n if (!membership) throw forbidden(\"No eres miembro del workspace del proyecto\");\n }\n\n const token = randomBytes(24).toString(\"hex\");\n const row = await prisma.channelLinkToken.create({\n data: {\n token,\n userId,\n projectId: projectId ?? null,\n expiresAt: new Date(Date.now() + LINK_TTL_MS),\n },\n });\n\n const botUsername = botUsernameFromToken(env.TELEGRAM_BOT_TOKEN);\n const deepLink = botUsername\n ? `https://t.me/${botUsername}?start=${token}`\n : null;\n\n trackServerEvent(analyticsEnabled, userId, \"telegram_link_started\");\n\n return {\n token: row.token,\n expiresAt: row.expiresAt,\n deepLink,\n // Fallback si no hay username: el usuario pega /start en el bot.\n startPayload: token,\n };\n}\n\n/**\n * Completa el vínculo desde el webhook /start .\n * Crea o reutiliza UserChannelConfig + ApiKey user-scoped.\n */\nexport async function completeLinkFromToken(\n token: string,\n telegramUserId: string,\n telegramUsername?: string | null\n) {\n // El link se completa fuera de la SPA (el bot habla directo con Telegram): se\n // detecta acá, al procesar /start, y se incluye el User ya en la misma query\n // para trackear `telegram_linked` sin un fetch aparte.\n const row = await prisma.channelLinkToken.findUnique({\n where: { token },\n include: { user: { select: { analyticsEnabled: true } } },\n });\n if (!row || row.usedAt) throw badRequest(\"Token de vínculo inválido o ya usado\", \"invalid_link_token\");\n if (row.expiresAt.getTime() < Date.now())\n throw badRequest(\"Token de vínculo expirado; genera uno nuevo desde Pemie\", \"link_token_expired\");\n\n const userId = row.userId;\n\n // Si otro usuario Pemie ya tiene este telegram, o este user tiene otro telegram: reemplazar.\n await prisma.$transaction(async (tx) => {\n await tx.channelLink.deleteMany({\n where: {\n OR: [\n { provider: PROVIDER, externalId: telegramUserId },\n { userId, provider: PROVIDER },\n ],\n },\n });\n await tx.channelLink.create({\n data: {\n userId,\n provider: PROVIDER,\n externalId: telegramUserId,\n username: telegramUsername ?? null,\n },\n });\n await tx.channelLinkToken.update({\n where: { id: row.id },\n data: { usedAt: new Date() },\n });\n });\n\n await ensureUserChannelConfig(userId, row.projectId);\n trackServerEvent(row.user.analyticsEnabled, userId, \"telegram_linked\");\n return { userId, projectId: row.projectId };\n}\n\n/** Asegura config + user MCP key (reutiliza si ya existe). */\nexport async function ensureUserChannelConfig(userId: string, defaultProjectId?: string | null) {\n const existing = await prisma.userChannelConfig.findUnique({ where: { userId } });\n if (existing) {\n if (defaultProjectId && !existing.defaultProjectId) {\n return prisma.userChannelConfig.update({\n where: { userId },\n data: { defaultProjectId, enabled: true },\n });\n }\n if (!existing.enabled) {\n return prisma.userChannelConfig.update({\n where: { userId },\n data: { enabled: true },\n });\n }\n return existing;\n }\n\n // Home workspace: del default project, o el primero donde sea member.\n let workspaceId: string | null = null;\n if (defaultProjectId) {\n const p = await prisma.project.findUnique({ where: { id: defaultProjectId } });\n workspaceId = p?.workspaceId ?? null;\n }\n if (!workspaceId) {\n const m = await prisma.membership.findFirst({\n where: { userId },\n orderBy: { createdAt: \"asc\" },\n });\n workspaceId = m?.workspaceId ?? null;\n }\n if (!workspaceId) throw badRequest(\"Necesitas un workspace para conectar Telegram\", \"no_workspace\");\n\n const { apiKey } = await agents.createApiKey(userId, workspaceId, {\n name: \"Telegram bot\",\n scopeLevel: \"user\",\n scopes: [...API_SCOPES],\n skipAdminCheck: true,\n });\n\n // createApiKey returns public view; we need the id\n return prisma.userChannelConfig.create({\n data: {\n userId,\n apiKeyId: apiKey.id,\n defaultProjectId: defaultProjectId ?? null,\n enabled: true,\n },\n });\n}\n\n/** Guarda / actualiza la LLM key del usuario (cifrada) y el proveedor activo. */\nexport async function setLlmKey(\n userId: string,\n analyticsEnabled: boolean,\n rawKey: string,\n opts?: { provider?: string; model?: string }\n) {\n try {\n const provider = parseLlmProvider(opts?.provider ?? \"anthropic\");\n const trimmed = rawKey.trim();\n validateLlmKey(provider, trimmed);\n\n await ensureUserChannelConfig(userId);\n const ciphertext = encryptSecret(trimmed);\n const last4 = trimmed.slice(-4);\n const requested = opts?.model?.trim();\n // Un modelo fuera del catálogo se rechaza igual que en `setChannelModel`: caer\n // al default en silencio dejaba al usuario creyendo que guardó otra cosa.\n if (requested && !isAllowedModel(provider, requested)) {\n throw badRequest(\n `Modelo inválido para ${provider}. Usa: ${listModelsForProvider(provider).join(\", \")}`,\n \"invalid_model\"\n );\n }\n const model = requested || CHANNEL_LLM_DEFAULT_MODELS[provider];\n\n await prisma.channelLlmCredential.upsert({\n where: { userId_provider: { userId, provider } },\n create: { userId, provider, llmKeyCiphertext: ciphertext, llmKeyLast4: last4 },\n update: { llmKeyCiphertext: ciphertext, llmKeyLast4: last4 },\n });\n\n const config = await prisma.userChannelConfig.update({\n where: { userId },\n data: {\n llmProvider: provider,\n llmKeyCiphertext: ciphertext,\n llmKeyLast4: last4,\n model,\n enabled: true,\n },\n });\n // provider/model son metadata, no secretos: nunca el valor de la key.\n trackServerEvent(analyticsEnabled, userId, \"telegram_llm_key_set\", { provider, model });\n return config;\n } catch (err) {\n const reason = err instanceof ServiceError ? err.code ?? \"unknown_error\" : \"unknown_error\";\n trackServerEvent(analyticsEnabled, userId, \"telegram_llm_key_set_failed\", { reason });\n throw err;\n }\n}\n\n/**\n * Borra la LLM key guardada de un proveedor.\n *\n * La key es del usuario y se factura a su cuenta: tiene que poder revocarla desde\n * el producto. Si además es la del proveedor activo se limpia el espejo en\n * `UserChannelConfig`, que es de donde leen `loadBotSession` y `getChannelStatus`.\n */\nexport async function deleteLlmKey(userId: string, rawProvider: string) {\n const provider = parseLlmProvider(rawProvider);\n\n await prisma.channelLlmCredential.deleteMany({ where: { userId, provider } });\n await prisma.userChannelConfig.updateMany({\n where: { userId, llmProvider: provider },\n data: { llmKeyCiphertext: null, llmKeyLast4: null },\n });\n\n return prisma.userChannelConfig.findUnique({ where: { userId } });\n}\n\n/** Cambia el proveedor activo si ya hay credential guardada. */\nexport async function setChannelProvider(userId: string, rawProvider: string) {\n const provider = parseLlmProvider(rawProvider);\n await ensureUserChannelConfig(userId);\n\n const cred = await prisma.channelLlmCredential.findUnique({\n where: { userId_provider: { userId, provider } },\n });\n if (!cred) {\n // Compat: key solo en UserChannelConfig del mismo proveedor\n const config = await prisma.userChannelConfig.findUnique({ where: { userId } });\n if (config?.llmProvider === provider && config.llmKeyCiphertext) {\n await prisma.channelLlmCredential.create({\n data: {\n userId,\n provider,\n llmKeyCiphertext: config.llmKeyCiphertext,\n llmKeyLast4: config.llmKeyLast4 ?? \"????\",\n },\n });\n } else {\n throw badRequest(\n `No hay API key guardada para ${provider}. Pégala en Pemie → Agente → Telegram.`,\n \"provider_key_missing\"\n );\n }\n }\n\n const fresh = await prisma.channelLlmCredential.findUniqueOrThrow({\n where: { userId_provider: { userId, provider } },\n });\n const config = await prisma.userChannelConfig.findUniqueOrThrow({ where: { userId } });\n const model = isAllowedModel(provider, config.model)\n ? config.model\n : CHANNEL_LLM_DEFAULT_MODELS[provider];\n\n return prisma.userChannelConfig.update({\n where: { userId },\n data: {\n llmProvider: provider,\n llmKeyCiphertext: fresh.llmKeyCiphertext,\n llmKeyLast4: fresh.llmKeyLast4,\n model,\n enabled: true,\n },\n });\n}\n\n/** Cambia el modelo del proveedor activo (catálogo fijo). */\nexport async function setChannelModel(userId: string, rawModel: string) {\n await ensureUserChannelConfig(userId);\n const config = await prisma.userChannelConfig.findUniqueOrThrow({ where: { userId } });\n const provider = parseLlmProvider(config.llmProvider);\n const model = rawModel.trim();\n if (!isAllowedModel(provider, model)) {\n throw badRequest(\n `Modelo inválido para ${provider}. Usa: ${listModelsForProvider(provider).join(\", \")}`,\n \"invalid_model\"\n );\n }\n return prisma.userChannelConfig.update({\n where: { userId },\n data: { model },\n });\n}\n\nfunction truncateContent(text: string, max = MESSAGE_CONTENT_MAX): string {\n if (text.length <= max) return text;\n return text.slice(0, max - 1) + \"…\";\n}\n\nexport async function listRecentChannelMessages(userId: string, limit = HISTORY_KEEP) {\n // Ordena por `seq`, no por `createdAt`: el par user+assistant se inserta en la\n // misma transacción y comparte timestamp, así que el desempate sería arbitrario\n // y podría abrir el historial con un turno \"assistant\" (400 del proveedor).\n const rows = await prisma.channelMessage.findMany({\n where: { userId, provider: PROVIDER },\n orderBy: { seq: \"desc\" },\n take: limit,\n select: { role: true, content: true, createdAt: true },\n });\n return rows.reverse();\n}\n\nexport async function getConversationSummary(userId: string): Promise {\n const config = await prisma.userChannelConfig.findUnique({\n where: { userId },\n select: { conversationSummary: true },\n });\n return config?.conversationSummary ?? null;\n}\n\nexport async function setConversationSummary(userId: string, summary: string | null) {\n const value = summary ? truncateContent(summary, SUMMARY_MAX) : null;\n await prisma.userChannelConfig.update({\n where: { userId },\n data: { conversationSummary: value },\n });\n}\n\n/**\n * Guarda el par user/assistant del turno, poda a HISTORY_KEEP y\n * devuelve los mensajes expulsados (para actualizar el resumen rolling).\n */\nexport async function recordTurnMessages(\n userId: string,\n userText: string,\n assistantText: string\n): Promise> {\n await prisma.channelMessage.createMany({\n data: [\n { userId, provider: PROVIDER, role: \"user\", content: truncateContent(userText) },\n { userId, provider: PROVIDER, role: \"assistant\", content: truncateContent(assistantText) },\n ],\n });\n\n // Mismo motivo que en `listRecentChannelMessages`: por `createdAt` la poda podría\n // expulsar el assistant y dejar huérfano su user del mismo turno.\n const all = await prisma.channelMessage.findMany({\n where: { userId, provider: PROVIDER },\n orderBy: { seq: \"asc\" },\n select: { id: true, role: true, content: true },\n });\n\n if (all.length <= HISTORY_KEEP) return [];\n\n const overflow = all.slice(0, all.length - HISTORY_KEEP);\n await prisma.channelMessage.deleteMany({\n where: { id: { in: overflow.map((m) => m.id) } },\n });\n return overflow.map((m) => ({ role: m.role, content: m.content }));\n}\n\nexport async function clearChannelMessages(userId: string) {\n await prisma.channelMessage.deleteMany({ where: { userId, provider: PROVIDER } });\n await prisma.userChannelConfig.updateMany({\n where: { userId },\n data: { conversationSummary: null },\n });\n}\n\nexport async function setDefaultProject(\n userId: string,\n analyticsEnabled: boolean,\n projectId: string | null\n) {\n await ensureUserChannelConfig(userId);\n if (projectId) {\n const project = await prisma.project.findUnique({ where: { id: projectId } });\n if (!project) throw notFound(\"Proyecto no encontrado\");\n const membership = await prisma.membership.findUnique({\n where: { userId_workspaceId: { workspaceId: project.workspaceId, userId } },\n });\n if (!membership) throw forbidden(\"No eres miembro del workspace del proyecto\");\n }\n const config = await prisma.userChannelConfig.update({\n where: { userId },\n data: { defaultProjectId: projectId },\n });\n trackServerEvent(analyticsEnabled, userId, \"telegram_default_project_set\");\n return config;\n}\n\n/** Desvincula Telegram y desactiva el canal (no borra la user key MCP ni credentials). */\nexport async function disconnectChannel(userId: string, analyticsEnabled: boolean) {\n await prisma.channelLink.deleteMany({ where: { userId, provider: PROVIDER } });\n await clearChannelMessages(userId).catch(() => {});\n const config = await prisma.userChannelConfig.findUnique({ where: { userId } });\n if (config) {\n await prisma.userChannelConfig.update({\n where: { userId },\n data: { enabled: false },\n });\n }\n trackServerEvent(analyticsEnabled, userId, \"telegram_disconnected\");\n return { ok: true };\n}\n\nexport type UpdateClaim = \"claimed\" | \"duplicate\" | \"rate_limited\";\n\n/**\n * Reserva un update entrante antes de procesarlo.\n *\n * Telegram reintenta la entrega cuando el webhook tarda o responde 5xx, y el\n * turno puede ejecutar tools de escritura (publicar informe, crear tarjeta):\n * repetirlo duplica efectos. La unique (provider, updateId) hace de candado, así\n * que el reintento cae en `duplicate` y no vuelve a ejecutar nada. Si el turno\n * se cae a mitad el update queda reservado sin `processedAt` — preferimos perder\n * una respuesta antes que duplicar escrituras.\n *\n * Las filas también son el rate limit: contar en DB funciona en serverless,\n * donde cada request puede caer en otra instancia y un Map de proceso no vale.\n */\nexport async function claimChannelUpdate(\n updateId: string,\n externalId: string\n): Promise {\n try {\n await prisma.channelUpdate.create({\n data: { provider: PROVIDER, updateId, externalId },\n });\n } catch (err) {\n if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === \"P2002\")\n return \"duplicate\";\n throw err;\n }\n\n const recent = await prisma.channelUpdate.count({\n where: {\n provider: PROVIDER,\n externalId,\n createdAt: { gte: new Date(Date.now() - RATE_WINDOW_MS) },\n },\n });\n\n // Poda oportunista: no hay cron, y la tabla no puede crecer sin techo.\n await prisma.channelUpdate\n .deleteMany({ where: { createdAt: { lt: new Date(Date.now() - UPDATE_RETENTION_MS) } } })\n .catch(() => {});\n\n return recent > RATE_MAX ? \"rate_limited\" : \"claimed\";\n}\n\n/** Cierra un update reservado (deja rastro de los turnos que sí terminaron). */\nexport async function markChannelUpdateProcessed(updateId: string) {\n await prisma.channelUpdate\n .updateMany({\n where: { provider: PROVIDER, updateId, processedAt: null },\n data: { processedAt: new Date() },\n })\n .catch(() => {});\n}\n\n/** Carga contexto completo del bot para un telegram user id. */\nexport async function loadBotSession(telegramUserId: string) {\n // Incluye analyticsEnabled en la misma query (no un fetch aparte): los comandos\n // del bot que trackean (/proyecto, /desvincular) lo leen de acá.\n const link = await prisma.channelLink.findUnique({\n where: { provider_externalId: { provider: PROVIDER, externalId: telegramUserId } },\n include: { user: { select: { analyticsEnabled: true } } },\n });\n if (!link) return null;\n\n const config = await prisma.userChannelConfig.findUnique({\n where: { userId: link.userId },\n include: {\n apiKey: true,\n defaultProject: { select: { id: true, name: true, slug: true, workspaceId: true } },\n },\n });\n if (!config || !config.enabled) return null;\n\n // Preferir credential del proveedor activo; fallback al espejo en config.\n const cred = await prisma.channelLlmCredential.findUnique({\n where: { userId_provider: { userId: link.userId, provider: config.llmProvider } },\n });\n const llmKeyCiphertext = cred?.llmKeyCiphertext ?? config.llmKeyCiphertext;\n const llmKeyLast4 = cred?.llmKeyLast4 ?? config.llmKeyLast4;\n\n return {\n link,\n config: {\n ...config,\n llmKeyCiphertext,\n llmKeyLast4,\n },\n };\n}\n"}, "6bf46d76b24e59299e7ef89ad794c74b60755b3d": {"t": 1785773328.8439565, "n": 19705, "h": "8ebc732231e697fd", "c": "// Canal Telegram: vincular cuenta, BYOK Anthropic, user MCP key.\n// El bot no guarda la MCP key en claro: usa el registro ApiKey vía invokeMcpTool.\n\nimport { randomBytes } from \"node:crypto\";\nimport {\n API_SCOPES,\n CHANNEL_LLM_PROVIDERS,\n CHANNEL_LLM_DEFAULT_MODELS,\n isAllowedModel,\n listModelsForProvider,\n type ChannelLlmProvider,\n} from \"@pemie/shared\";\nimport { Prisma } from \"@prisma/client\";\nimport { prisma } from \"../db.js\";\nimport { env } from \"../env.js\";\nimport { encryptSecret } from \"../lib/secrets.js\";\nimport { badRequest, forbidden, notFound } from \"./errors.js\";\nimport * as agents from \"./agents.js\";\n\nconst LINK_TTL_MS = 15 * 60 * 1000;\nconst PROVIDER = \"telegram\";\n\nconst RATE_WINDOW_MS = 60_000;\nconst RATE_MAX = 20;\n/** Retención de updates vistos: cubre de sobra la ventana de reintentos. */\nconst UPDATE_RETENTION_MS = 24 * 60 * 60 * 1000;\n\n/** Ventana de historial enviada al LLM (user + assistant). */\nexport const HISTORY_KEEP = 10;\nconst MESSAGE_CONTENT_MAX = 4_000;\nconst SUMMARY_MAX = 1_500;\n\nfunction botUsernameFromToken(_token: string | undefined): string | null {\n return env.TELEGRAM_BOT_USERNAME?.trim() || null;\n}\n\nfunction parseLlmProvider(raw: string | undefined | null): ChannelLlmProvider {\n const p = (raw ?? \"anthropic\") as ChannelLlmProvider;\n if (!(CHANNEL_LLM_PROVIDERS as readonly string[]).includes(p))\n throw badRequest(`Proveedor LLM inválido: ${raw}`, \"invalid_llm_provider\");\n return p;\n}\n\nfunction validateLlmKey(provider: ChannelLlmProvider, key: string) {\n if (key.length < 20) throw badRequest(\"La API key es demasiado corta\", \"invalid_llm_key\");\n if (provider === \"anthropic\") {\n if (!key.startsWith(\"sk-ant-\") && !key.startsWith(\"sk-\"))\n throw badRequest(\"La key de Anthropic no parece válida (sk-ant-…)\", \"invalid_llm_key\");\n } else if (provider === \"openai\") {\n if (!key.startsWith(\"sk-\"))\n throw badRequest(\"La key de OpenAI no parece válida (sk-…)\", \"invalid_llm_key\");\n }\n // deepseek: acepta sk-… u otros prefijos comerciales\n}\n\n/** Estado del canal Telegram del usuario (sin secretos). */\nexport async function getChannelStatus(userId: string) {\n const [link, config, credentials] = await Promise.all([\n prisma.channelLink.findUnique({ where: { userId_provider: { userId, provider: PROVIDER } } }),\n prisma.userChannelConfig.findUnique({\n where: { userId },\n include: { apiKey: true, defaultProject: { select: { id: true, name: true, slug: true } } },\n }),\n prisma.channelLlmCredential.findMany({ where: { userId } }),\n ]);\n\n const botConfigured = Boolean(env.TELEGRAM_BOT_TOKEN?.trim());\n const botUsername = botUsernameFromToken(env.TELEGRAM_BOT_TOKEN);\n const llmProvider = (CHANNEL_LLM_PROVIDERS as readonly string[]).includes(config?.llmProvider ?? \"\")\n ? (config!.llmProvider as ChannelLlmProvider)\n : \"anthropic\";\n\n const providers = Object.fromEntries(\n CHANNEL_LLM_PROVIDERS.map((p) => {\n const cred = credentials.find((c) => c.provider === p);\n const legacy =\n !cred && config?.llmProvider === p && config.llmKeyCiphertext\n ? { last4: config.llmKeyLast4 }\n : null;\n return [\n p,\n {\n hasKey: Boolean(cred || legacy),\n last4: cred?.llmKeyLast4 ?? legacy?.last4 ?? null,\n models: [...listModelsForProvider(p)],\n },\n ];\n })\n ) as Record;\n\n const activeHasKey = providers[llmProvider].hasKey;\n\n return {\n botConfigured,\n botUsername,\n linked: Boolean(link),\n telegramUsername: link?.username ?? null,\n linkedAt: link?.linkedAt ?? null,\n enabled: config?.enabled ?? false,\n hasLlmKey: activeHasKey,\n llmKeyLast4: providers[llmProvider].last4,\n llmProvider,\n model: config?.model ?? CHANNEL_LLM_DEFAULT_MODELS[llmProvider],\n models: [...listModelsForProvider(llmProvider)],\n providers,\n defaultProject: config?.defaultProject ?? null,\n apiKeyPrefix: config?.apiKey?.prefix ?? null,\n ready: Boolean(link && config?.enabled && activeHasKey),\n };\n}\n\n/**\n * Crea un token one-shot para deep link t.me/Bot?start=.\n * Al completar el link se asegura UserChannelConfig + user MCP key.\n */\nexport async function createLinkToken(userId: string, projectId?: string | null) {\n if (!env.TELEGRAM_BOT_TOKEN?.trim())\n throw badRequest(\"Telegram no está configurado en el servidor\", \"telegram_not_configured\");\n\n if (projectId) {\n const project = await prisma.project.findUnique({ where: { id: projectId } });\n if (!project) throw notFound(\"Proyecto no encontrado\");\n const membership = await prisma.membership.findUnique({\n where: { userId_workspaceId: { workspaceId: project.workspaceId, userId } },\n });\n if (!membership) throw forbidden(\"No eres miembro del workspace del proyecto\");\n }\n\n const token = randomBytes(24).toString(\"hex\");\n const row = await prisma.channelLinkToken.create({\n data: {\n token,\n userId,\n projectId: projectId ?? null,\n expiresAt: new Date(Date.now() + LINK_TTL_MS),\n },\n });\n\n const botUsername = botUsernameFromToken(env.TELEGRAM_BOT_TOKEN);\n const deepLink = botUsername\n ? `https://t.me/${botUsername}?start=${token}`\n : null;\n\n return {\n token: row.token,\n expiresAt: row.expiresAt,\n deepLink,\n // Fallback si no hay username: el usuario pega /start en el bot.\n startPayload: token,\n };\n}\n\n/**\n * Completa el vínculo desde el webhook /start .\n * Crea o reutiliza UserChannelConfig + ApiKey user-scoped.\n */\nexport async function completeLinkFromToken(\n token: string,\n telegramUserId: string,\n telegramUsername?: string | null\n) {\n const row = await prisma.channelLinkToken.findUnique({ where: { token } });\n if (!row || row.usedAt) throw badRequest(\"Token de vínculo inválido o ya usado\", \"invalid_link_token\");\n if (row.expiresAt.getTime() < Date.now())\n throw badRequest(\"Token de vínculo expirado; genera uno nuevo desde Pemie\", \"link_token_expired\");\n\n const userId = row.userId;\n\n // Si otro usuario Pemie ya tiene este telegram, o este user tiene otro telegram: reemplazar.\n await prisma.$transaction(async (tx) => {\n await tx.channelLink.deleteMany({\n where: {\n OR: [\n { provider: PROVIDER, externalId: telegramUserId },\n { userId, provider: PROVIDER },\n ],\n },\n });\n await tx.channelLink.create({\n data: {\n userId,\n provider: PROVIDER,\n externalId: telegramUserId,\n username: telegramUsername ?? null,\n },\n });\n await tx.channelLinkToken.update({\n where: { id: row.id },\n data: { usedAt: new Date() },\n });\n });\n\n await ensureUserChannelConfig(userId, row.projectId);\n return { userId, projectId: row.projectId };\n}\n\n/** Asegura config + user MCP key (reutiliza si ya existe). */\nexport async function ensureUserChannelConfig(userId: string, defaultProjectId?: string | null) {\n const existing = await prisma.userChannelConfig.findUnique({ where: { userId } });\n if (existing) {\n if (defaultProjectId && !existing.defaultProjectId) {\n return prisma.userChannelConfig.update({\n where: { userId },\n data: { defaultProjectId, enabled: true },\n });\n }\n if (!existing.enabled) {\n return prisma.userChannelConfig.update({\n where: { userId },\n data: { enabled: true },\n });\n }\n return existing;\n }\n\n // Home workspace: del default project, o el primero donde sea member.\n let workspaceId: string | null = null;\n if (defaultProjectId) {\n const p = await prisma.project.findUnique({ where: { id: defaultProjectId } });\n workspaceId = p?.workspaceId ?? null;\n }\n if (!workspaceId) {\n const m = await prisma.membership.findFirst({\n where: { userId },\n orderBy: { createdAt: \"asc\" },\n });\n workspaceId = m?.workspaceId ?? null;\n }\n if (!workspaceId) throw badRequest(\"Necesitas un workspace para conectar Telegram\", \"no_workspace\");\n\n const { apiKey } = await agents.createApiKey(userId, workspaceId, {\n name: \"Telegram bot\",\n scopeLevel: \"user\",\n scopes: [...API_SCOPES],\n skipAdminCheck: true,\n });\n\n // createApiKey returns public view; we need the id\n return prisma.userChannelConfig.create({\n data: {\n userId,\n apiKeyId: apiKey.id,\n defaultProjectId: defaultProjectId ?? null,\n enabled: true,\n },\n });\n}\n\n/** Guarda / actualiza la LLM key del usuario (cifrada) y el proveedor activo. */\nexport async function setLlmKey(\n userId: string,\n rawKey: string,\n opts?: { provider?: string; model?: string }\n) {\n const provider = parseLlmProvider(opts?.provider ?? \"anthropic\");\n const trimmed = rawKey.trim();\n validateLlmKey(provider, trimmed);\n\n await ensureUserChannelConfig(userId);\n const ciphertext = encryptSecret(trimmed);\n const last4 = trimmed.slice(-4);\n const requested = opts?.model?.trim();\n // Un modelo fuera del catálogo se rechaza igual que en `setChannelModel`: caer\n // al default en silencio dejaba al usuario creyendo que guardó otra cosa.\n if (requested && !isAllowedModel(provider, requested)) {\n throw badRequest(\n `Modelo inválido para ${provider}. Usa: ${listModelsForProvider(provider).join(\", \")}`,\n \"invalid_model\"\n );\n }\n const model = requested || CHANNEL_LLM_DEFAULT_MODELS[provider];\n\n await prisma.channelLlmCredential.upsert({\n where: { userId_provider: { userId, provider } },\n create: { userId, provider, llmKeyCiphertext: ciphertext, llmKeyLast4: last4 },\n update: { llmKeyCiphertext: ciphertext, llmKeyLast4: last4 },\n });\n\n return prisma.userChannelConfig.update({\n where: { userId },\n data: {\n llmProvider: provider,\n llmKeyCiphertext: ciphertext,\n llmKeyLast4: last4,\n model,\n enabled: true,\n },\n });\n}\n\n/**\n * Borra la LLM key guardada de un proveedor.\n *\n * La key es del usuario y se factura a su cuenta: tiene que poder revocarla desde\n * el producto. Si además es la del proveedor activo se limpia el espejo en\n * `UserChannelConfig`, que es de donde leen `loadBotSession` y `getChannelStatus`.\n */\nexport async function deleteLlmKey(userId: string, rawProvider: string) {\n const provider = parseLlmProvider(rawProvider);\n\n await prisma.channelLlmCredential.deleteMany({ where: { userId, provider } });\n await prisma.userChannelConfig.updateMany({\n where: { userId, llmProvider: provider },\n data: { llmKeyCiphertext: null, llmKeyLast4: null },\n });\n\n return prisma.userChannelConfig.findUnique({ where: { userId } });\n}\n\n/** Cambia el proveedor activo si ya hay credential guardada. */\nexport async function setChannelProvider(userId: string, rawProvider: string) {\n const provider = parseLlmProvider(rawProvider);\n await ensureUserChannelConfig(userId);\n\n const cred = await prisma.channelLlmCredential.findUnique({\n where: { userId_provider: { userId, provider } },\n });\n if (!cred) {\n // Compat: key solo en UserChannelConfig del mismo proveedor\n const config = await prisma.userChannelConfig.findUnique({ where: { userId } });\n if (config?.llmProvider === provider && config.llmKeyCiphertext) {\n await prisma.channelLlmCredential.create({\n data: {\n userId,\n provider,\n llmKeyCiphertext: config.llmKeyCiphertext,\n llmKeyLast4: config.llmKeyLast4 ?? \"????\",\n },\n });\n } else {\n throw badRequest(\n `No hay API key guardada para ${provider}. Pégala en Pemie → Agente → Telegram.`,\n \"provider_key_missing\"\n );\n }\n }\n\n const fresh = await prisma.channelLlmCredential.findUniqueOrThrow({\n where: { userId_provider: { userId, provider } },\n });\n const config = await prisma.userChannelConfig.findUniqueOrThrow({ where: { userId } });\n const model = isAllowedModel(provider, config.model)\n ? config.model\n : CHANNEL_LLM_DEFAULT_MODELS[provider];\n\n return prisma.userChannelConfig.update({\n where: { userId },\n data: {\n llmProvider: provider,\n llmKeyCiphertext: fresh.llmKeyCiphertext,\n llmKeyLast4: fresh.llmKeyLast4,\n model,\n enabled: true,\n },\n });\n}\n\n/** Cambia el modelo del proveedor activo (catálogo fijo). */\nexport async function setChannelModel(userId: string, rawModel: string) {\n await ensureUserChannelConfig(userId);\n const config = await prisma.userChannelConfig.findUniqueOrThrow({ where: { userId } });\n const provider = parseLlmProvider(config.llmProvider);\n const model = rawModel.trim();\n if (!isAllowedModel(provider, model)) {\n throw badRequest(\n `Modelo inválido para ${provider}. Usa: ${listModelsForProvider(provider).join(\", \")}`,\n \"invalid_model\"\n );\n }\n return prisma.userChannelConfig.update({\n where: { userId },\n data: { model },\n });\n}\n\nfunction truncateContent(text: string, max = MESSAGE_CONTENT_MAX): string {\n if (text.length <= max) return text;\n return text.slice(0, max - 1) + \"…\";\n}\n\nexport async function listRecentChannelMessages(userId: string, limit = HISTORY_KEEP) {\n // Ordena por `seq`, no por `createdAt`: el par user+assistant se inserta en la\n // misma transacción y comparte timestamp, así que el desempate sería arbitrario\n // y podría abrir el historial con un turno \"assistant\" (400 del proveedor).\n const rows = await prisma.channelMessage.findMany({\n where: { userId, provider: PROVIDER },\n orderBy: { seq: \"desc\" },\n take: limit,\n select: { role: true, content: true, createdAt: true },\n });\n return rows.reverse();\n}\n\nexport async function getConversationSummary(userId: string): Promise {\n const config = await prisma.userChannelConfig.findUnique({\n where: { userId },\n select: { conversationSummary: true },\n });\n return config?.conversationSummary ?? null;\n}\n\nexport async function setConversationSummary(userId: string, summary: string | null) {\n const value = summary ? truncateContent(summary, SUMMARY_MAX) : null;\n await prisma.userChannelConfig.update({\n where: { userId },\n data: { conversationSummary: value },\n });\n}\n\n/**\n * Guarda el par user/assistant del turno, poda a HISTORY_KEEP y\n * devuelve los mensajes expulsados (para actualizar el resumen rolling).\n */\nexport async function recordTurnMessages(\n userId: string,\n userText: string,\n assistantText: string\n): Promise> {\n await prisma.channelMessage.createMany({\n data: [\n { userId, provider: PROVIDER, role: \"user\", content: truncateContent(userText) },\n { userId, provider: PROVIDER, role: \"assistant\", content: truncateContent(assistantText) },\n ],\n });\n\n // Mismo motivo que en `listRecentChannelMessages`: por `createdAt` la poda podría\n // expulsar el assistant y dejar huérfano su user del mismo turno.\n const all = await prisma.channelMessage.findMany({\n where: { userId, provider: PROVIDER },\n orderBy: { seq: \"asc\" },\n select: { id: true, role: true, content: true },\n });\n\n if (all.length <= HISTORY_KEEP) return [];\n\n const overflow = all.slice(0, all.length - HISTORY_KEEP);\n await prisma.channelMessage.deleteMany({\n where: { id: { in: overflow.map((m) => m.id) } },\n });\n return overflow.map((m) => ({ role: m.role, content: m.content }));\n}\n\nexport async function clearChannelMessages(userId: string) {\n await prisma.channelMessage.deleteMany({ where: { userId, provider: PROVIDER } });\n await prisma.userChannelConfig.updateMany({\n where: { userId },\n data: { conversationSummary: null },\n });\n}\n\nexport async function setDefaultProject(userId: string, projectId: string | null) {\n await ensureUserChannelConfig(userId);\n if (projectId) {\n const project = await prisma.project.findUnique({ where: { id: projectId } });\n if (!project) throw notFound(\"Proyecto no encontrado\");\n const membership = await prisma.membership.findUnique({\n where: { userId_workspaceId: { workspaceId: project.workspaceId, userId } },\n });\n if (!membership) throw forbidden(\"No eres miembro del workspace del proyecto\");\n }\n return prisma.userChannelConfig.update({\n where: { userId },\n data: { defaultProjectId: projectId },\n });\n}\n\n/** Desvincula Telegram y desactiva el canal (no borra la user key MCP ni credentials). */\nexport async function disconnectChannel(userId: string) {\n await prisma.channelLink.deleteMany({ where: { userId, provider: PROVIDER } });\n await clearChannelMessages(userId).catch(() => {});\n const config = await prisma.userChannelConfig.findUnique({ where: { userId } });\n if (config) {\n await prisma.userChannelConfig.update({\n where: { userId },\n data: { enabled: false },\n });\n }\n return { ok: true };\n}\n\nexport type UpdateClaim = \"claimed\" | \"duplicate\" | \"rate_limited\";\n\n/**\n * Reserva un update entrante antes de procesarlo.\n *\n * Telegram reintenta la entrega cuando el webhook tarda o responde 5xx, y el\n * turno puede ejecutar tools de escritura (publicar informe, crear tarjeta):\n * repetirlo duplica efectos. La unique (provider, updateId) hace de candado, así\n * que el reintento cae en `duplicate` y no vuelve a ejecutar nada. Si el turno\n * se cae a mitad el update queda reservado sin `processedAt` — preferimos perder\n * una respuesta antes que duplicar escrituras.\n *\n * Las filas también son el rate limit: contar en DB funciona en serverless,\n * donde cada request puede caer en otra instancia y un Map de proceso no vale.\n */\nexport async function claimChannelUpdate(\n updateId: string,\n externalId: string\n): Promise {\n try {\n await prisma.channelUpdate.create({\n data: { provider: PROVIDER, updateId, externalId },\n });\n } catch (err) {\n if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === \"P2002\")\n return \"duplicate\";\n throw err;\n }\n\n const recent = await prisma.channelUpdate.count({\n where: {\n provider: PROVIDER,\n externalId,\n createdAt: { gte: new Date(Date.now() - RATE_WINDOW_MS) },\n },\n });\n\n // Poda oportunista: no hay cron, y la tabla no puede crecer sin techo.\n await prisma.channelUpdate\n .deleteMany({ where: { createdAt: { lt: new Date(Date.now() - UPDATE_RETENTION_MS) } } })\n .catch(() => {});\n\n return recent > RATE_MAX ? \"rate_limited\" : \"claimed\";\n}\n\n/** Cierra un update reservado (deja rastro de los turnos que sí terminaron). */\nexport async function markChannelUpdateProcessed(updateId: string) {\n await prisma.channelUpdate\n .updateMany({\n where: { provider: PROVIDER, updateId, processedAt: null },\n data: { processedAt: new Date() },\n })\n .catch(() => {});\n}\n\n/** Carga contexto completo del bot para un telegram user id. */\nexport async function loadBotSession(telegramUserId: string) {\n const link = await prisma.channelLink.findUnique({\n where: { provider_externalId: { provider: PROVIDER, externalId: telegramUserId } },\n });\n if (!link) return null;\n\n const config = await prisma.userChannelConfig.findUnique({\n where: { userId: link.userId },\n include: {\n apiKey: true,\n defaultProject: { select: { id: true, name: true, slug: true, workspaceId: true } },\n },\n });\n if (!config || !config.enabled) return null;\n\n // Preferir credential del proveedor activo; fallback al espejo en config.\n const cred = await prisma.channelLlmCredential.findUnique({\n where: { userId_provider: { userId: link.userId, provider: config.llmProvider } },\n });\n const llmKeyCiphertext = cred?.llmKeyCiphertext ?? config.llmKeyCiphertext;\n const llmKeyLast4 = cred?.llmKeyLast4 ?? config.llmKeyLast4;\n\n return {\n link,\n config: {\n ...config,\n llmKeyCiphertext,\n llmKeyLast4,\n },\n };\n}\n"}, "601386c65bfbd498812dae612f6824a094c45043": {"t": 1785773330.1088576, "n": 11907, "h": "17812340f5ae770a", "c": "// Canal Telegram: vincular cuenta, BYOK Anthropic, user MCP key.\n// El bot no guarda la MCP key en claro: usa el registro ApiKey vía invokeMcpTool.\n\nimport { randomBytes } from \"node:crypto\";\nimport {\n API_SCOPES,\n CHANNEL_LLM_PROVIDERS,\n CHANNEL_LLM_DEFAULT_MODELS,\n type ChannelLlmProvider,\n} from \"@pemie/shared\";\nimport { Prisma } from \"@prisma/client\";\nimport { prisma } from \"../db.js\";\nimport { env } from \"../env.js\";\nimport { encryptSecret } from \"../lib/secrets.js\";\nimport { badRequest, forbidden, notFound } from \"./errors.js\";\nimport * as agents from \"./agents.js\";\n\nconst LINK_TTL_MS = 15 * 60 * 1000;\nconst PROVIDER = \"telegram\";\n\nconst RATE_WINDOW_MS = 60_000;\nconst RATE_MAX = 20;\n/** Retención de updates vistos: cubre de sobra la ventana de reintentos. */\nconst UPDATE_RETENTION_MS = 24 * 60 * 60 * 1000;\n\nfunction botUsernameFromToken(_token: string | undefined): string | null {\n return env.TELEGRAM_BOT_USERNAME?.trim() || null;\n}\n\nfunction parseLlmProvider(raw: string | undefined | null): ChannelLlmProvider {\n const p = (raw ?? \"anthropic\") as ChannelLlmProvider;\n if (!(CHANNEL_LLM_PROVIDERS as readonly string[]).includes(p))\n throw badRequest(`Proveedor LLM inválido: ${raw}`, \"invalid_llm_provider\");\n return p;\n}\n\nfunction validateLlmKey(provider: ChannelLlmProvider, key: string) {\n if (key.length < 20) throw badRequest(\"La API key es demasiado corta\", \"invalid_llm_key\");\n if (provider === \"anthropic\") {\n if (!key.startsWith(\"sk-ant-\") && !key.startsWith(\"sk-\"))\n throw badRequest(\"La key de Anthropic no parece válida (sk-ant-…)\", \"invalid_llm_key\");\n } else if (provider === \"openai\") {\n if (!key.startsWith(\"sk-\"))\n throw badRequest(\"La key de OpenAI no parece válida (sk-…)\", \"invalid_llm_key\");\n }\n // deepseek: acepta sk-… u otros prefijos comerciales\n}\n\n/** Estado del canal Telegram del usuario (sin secretos). */\nexport async function getChannelStatus(userId: string) {\n const [link, config] = await Promise.all([\n prisma.channelLink.findUnique({ where: { userId_provider: { userId, provider: PROVIDER } } }),\n prisma.userChannelConfig.findUnique({\n where: { userId },\n include: { apiKey: true, defaultProject: { select: { id: true, name: true, slug: true } } },\n }),\n ]);\n\n const botConfigured = Boolean(env.TELEGRAM_BOT_TOKEN?.trim());\n const botUsername = botUsernameFromToken(env.TELEGRAM_BOT_TOKEN);\n const llmProvider = (CHANNEL_LLM_PROVIDERS as readonly string[]).includes(config?.llmProvider ?? \"\")\n ? (config!.llmProvider as ChannelLlmProvider)\n : \"anthropic\";\n\n return {\n botConfigured,\n botUsername,\n linked: Boolean(link),\n telegramUsername: link?.username ?? null,\n linkedAt: link?.linkedAt ?? null,\n enabled: config?.enabled ?? false,\n hasLlmKey: Boolean(config?.llmKeyCiphertext),\n llmKeyLast4: config?.llmKeyLast4 ?? null,\n llmProvider,\n model: config?.model ?? CHANNEL_LLM_DEFAULT_MODELS[llmProvider],\n defaultProject: config?.defaultProject ?? null,\n apiKeyPrefix: config?.apiKey?.prefix ?? null,\n ready: Boolean(link && config?.enabled && config.llmKeyCiphertext),\n };\n}\n\n/**\n * Crea un token one-shot para deep link t.me/Bot?start=.\n * Al completar el link se asegura UserChannelConfig + user MCP key.\n */\nexport async function createLinkToken(userId: string, projectId?: string | null) {\n if (!env.TELEGRAM_BOT_TOKEN?.trim())\n throw badRequest(\"Telegram no está configurado en el servidor\", \"telegram_not_configured\");\n\n if (projectId) {\n const project = await prisma.project.findUnique({ where: { id: projectId } });\n if (!project) throw notFound(\"Proyecto no encontrado\");\n const membership = await prisma.membership.findUnique({\n where: { userId_workspaceId: { workspaceId: project.workspaceId, userId } },\n });\n if (!membership) throw forbidden(\"No eres miembro del workspace del proyecto\");\n }\n\n const token = randomBytes(24).toString(\"hex\");\n const row = await prisma.channelLinkToken.create({\n data: {\n token,\n userId,\n projectId: projectId ?? null,\n expiresAt: new Date(Date.now() + LINK_TTL_MS),\n },\n });\n\n const botUsername = botUsernameFromToken(env.TELEGRAM_BOT_TOKEN);\n const deepLink = botUsername\n ? `https://t.me/${botUsername}?start=${token}`\n : null;\n\n return {\n token: row.token,\n expiresAt: row.expiresAt,\n deepLink,\n // Fallback si no hay username: el usuario pega /start en el bot.\n startPayload: token,\n };\n}\n\n/**\n * Completa el vínculo desde el webhook /start .\n * Crea o reutiliza UserChannelConfig + ApiKey user-scoped.\n */\nexport async function completeLinkFromToken(\n token: string,\n telegramUserId: string,\n telegramUsername?: string | null\n) {\n const row = await prisma.channelLinkToken.findUnique({ where: { token } });\n if (!row || row.usedAt) throw badRequest(\"Token de vínculo inválido o ya usado\", \"invalid_link_token\");\n if (row.expiresAt.getTime() < Date.now())\n throw badRequest(\"Token de vínculo expirado; genera uno nuevo desde Pemie\", \"link_token_expired\");\n\n const userId = row.userId;\n\n // Si otro usuario Pemie ya tiene este telegram, o este user tiene otro telegram: reemplazar.\n await prisma.$transaction(async (tx) => {\n await tx.channelLink.deleteMany({\n where: {\n OR: [\n { provider: PROVIDER, externalId: telegramUserId },\n { userId, provider: PROVIDER },\n ],\n },\n });\n await tx.channelLink.create({\n data: {\n userId,\n provider: PROVIDER,\n externalId: telegramUserId,\n username: telegramUsername ?? null,\n },\n });\n await tx.channelLinkToken.update({\n where: { id: row.id },\n data: { usedAt: new Date() },\n });\n });\n\n await ensureUserChannelConfig(userId, row.projectId);\n return { userId, projectId: row.projectId };\n}\n\n/** Asegura config + user MCP key (reutiliza si ya existe). */\nexport async function ensureUserChannelConfig(userId: string, defaultProjectId?: string | null) {\n const existing = await prisma.userChannelConfig.findUnique({ where: { userId } });\n if (existing) {\n if (defaultProjectId && !existing.defaultProjectId) {\n return prisma.userChannelConfig.update({\n where: { userId },\n data: { defaultProjectId, enabled: true },\n });\n }\n if (!existing.enabled) {\n return prisma.userChannelConfig.update({\n where: { userId },\n data: { enabled: true },\n });\n }\n return existing;\n }\n\n // Home workspace: del default project, o el primero donde sea member.\n let workspaceId: string | null = null;\n if (defaultProjectId) {\n const p = await prisma.project.findUnique({ where: { id: defaultProjectId } });\n workspaceId = p?.workspaceId ?? null;\n }\n if (!workspaceId) {\n const m = await prisma.membership.findFirst({\n where: { userId },\n orderBy: { createdAt: \"asc\" },\n });\n workspaceId = m?.workspaceId ?? null;\n }\n if (!workspaceId) throw badRequest(\"Necesitas un workspace para conectar Telegram\", \"no_workspace\");\n\n const { apiKey } = await agents.createApiKey(userId, workspaceId, {\n name: \"Telegram bot\",\n scopeLevel: \"user\",\n scopes: [...API_SCOPES],\n skipAdminCheck: true,\n });\n\n // createApiKey returns public view; we need the id\n return prisma.userChannelConfig.create({\n data: {\n userId,\n apiKeyId: apiKey.id,\n defaultProjectId: defaultProjectId ?? null,\n enabled: true,\n },\n });\n}\n\n/** Guarda / actualiza la LLM key del usuario (cifrada) y el proveedor. */\nexport async function setLlmKey(\n userId: string,\n rawKey: string,\n opts?: { provider?: string; model?: string }\n) {\n const provider = parseLlmProvider(opts?.provider ?? \"anthropic\");\n const trimmed = rawKey.trim();\n validateLlmKey(provider, trimmed);\n\n await ensureUserChannelConfig(userId);\n const ciphertext = encryptSecret(trimmed);\n const last4 = trimmed.slice(-4);\n const model = opts?.model?.trim() || CHANNEL_LLM_DEFAULT_MODELS[provider];\n\n return prisma.userChannelConfig.update({\n where: { userId },\n data: {\n llmProvider: provider,\n llmKeyCiphertext: ciphertext,\n llmKeyLast4: last4,\n model,\n enabled: true,\n },\n });\n}\n\nexport async function setDefaultProject(userId: string, projectId: string | null) {\n await ensureUserChannelConfig(userId);\n if (projectId) {\n const project = await prisma.project.findUnique({ where: { id: projectId } });\n if (!project) throw notFound(\"Proyecto no encontrado\");\n const membership = await prisma.membership.findUnique({\n where: { userId_workspaceId: { workspaceId: project.workspaceId, userId } },\n });\n if (!membership) throw forbidden(\"No eres miembro del workspace del proyecto\");\n }\n return prisma.userChannelConfig.update({\n where: { userId },\n data: { defaultProjectId: projectId },\n });\n}\n\n/** Desvincula Telegram y desactiva el canal (no borra la user key MCP). */\nexport async function disconnectChannel(userId: string) {\n await prisma.channelLink.deleteMany({ where: { userId, provider: PROVIDER } });\n const config = await prisma.userChannelConfig.findUnique({ where: { userId } });\n if (config) {\n await prisma.userChannelConfig.update({\n where: { userId },\n data: { enabled: false, llmKeyCiphertext: null, llmKeyLast4: null },\n });\n }\n return { ok: true };\n}\n\nexport type UpdateClaim = \"claimed\" | \"duplicate\" | \"rate_limited\";\n\n/**\n * Reserva un update entrante antes de procesarlo.\n *\n * Telegram reintenta la entrega cuando el webhook tarda o responde 5xx, y el\n * turno puede ejecutar tools de escritura (publicar informe, crear tarjeta):\n * repetirlo duplica efectos. La unique (provider, updateId) hace de candado, así\n * que el reintento cae en `duplicate` y no vuelve a ejecutar nada. Si el turno\n * se cae a mitad el update queda reservado sin `processedAt` — preferimos perder\n * una respuesta antes que duplicar escrituras.\n *\n * Las filas también son el rate limit: contar en DB funciona en serverless,\n * donde cada request puede caer en otra instancia y un Map de proceso no vale.\n */\nexport async function claimChannelUpdate(\n updateId: string,\n externalId: string\n): Promise {\n try {\n await prisma.channelUpdate.create({\n data: { provider: PROVIDER, updateId, externalId },\n });\n } catch (err) {\n if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === \"P2002\")\n return \"duplicate\";\n throw err;\n }\n\n const recent = await prisma.channelUpdate.count({\n where: {\n provider: PROVIDER,\n externalId,\n createdAt: { gte: new Date(Date.now() - RATE_WINDOW_MS) },\n },\n });\n\n // Poda oportunista: no hay cron, y la tabla no puede crecer sin techo.\n await prisma.channelUpdate\n .deleteMany({ where: { createdAt: { lt: new Date(Date.now() - UPDATE_RETENTION_MS) } } })\n .catch(() => {});\n\n return recent > RATE_MAX ? \"rate_limited\" : \"claimed\";\n}\n\n/** Cierra un update reservado (deja rastro de los turnos que sí terminaron). */\nexport async function markChannelUpdateProcessed(updateId: string) {\n await prisma.channelUpdate\n .updateMany({\n where: { provider: PROVIDER, updateId, processedAt: null },\n data: { processedAt: new Date() },\n })\n .catch(() => {});\n}\n\n/** Carga contexto completo del bot para un telegram user id. */\nexport async function loadBotSession(telegramUserId: string) {\n const link = await prisma.channelLink.findUnique({\n where: { provider_externalId: { provider: PROVIDER, externalId: telegramUserId } },\n });\n if (!link) return null;\n\n const config = await prisma.userChannelConfig.findUnique({\n where: { userId: link.userId },\n include: {\n apiKey: true,\n defaultProject: { select: { id: true, name: true, slug: true, workspaceId: true } },\n },\n });\n if (!config || !config.enabled) return null;\n\n return { link, config };\n}\n"}}, "apps/api/src/rest/channels.ts": {"1a7611d19def63b2b535c799d40dfe5058d69d17": {"t": 1785747440.0758705, "n": 2554, "h": "8568e43ce1739d03", "c": "// REST del canal Telegram: status, link-token, llm-key, default project, disconnect.\n\nimport { Hono } from \"hono\";\nimport { z } from \"zod\";\nimport { type AppEnv, requireUser } from \"./http.js\";\nimport { badRequest } from \"../services/errors.js\";\nimport * as channels from \"../services/channels.js\";\n\nconst llmKeySchema = z.object({\n apiKey: z.string().min(20),\n provider: z.enum([\"anthropic\", \"openai\", \"deepseek\"]).optional(),\n model: z.string().optional(),\n});\n\nconst defaultProjectSchema = z.object({\n projectId: z.string().nullable(),\n});\n\nconst linkTokenSchema = z.object({\n projectId: z.string().optional(),\n});\n\nexport function channelRoutes() {\n const app = new Hono();\n\n app.get(\"/telegram\", async (c) => {\n const user = requireUser(c);\n return c.json({ channel: await channels.getChannelStatus(user.id) });\n });\n\n app.post(\"/telegram/link-token\", async (c) => {\n const user = requireUser(c);\n const body = linkTokenSchema.safeParse(await c.req.json().catch(() => ({})));\n if (!body.success) throw badRequest(\"Body inválido\", \"invalid_body\");\n const result = await channels.createLinkToken(user.id, user.analyticsEnabled, body.data.projectId);\n return c.json(result, 201);\n });\n\n app.put(\"/telegram/llm-key\", async (c) => {\n const user = requireUser(c);\n const body = llmKeySchema.safeParse(await c.req.json().catch(() => null));\n if (!body.success) throw badRequest(\"API key LLM inválida\", \"invalid_body\");\n await channels.setLlmKey(user.id, user.analyticsEnabled, body.data.apiKey, {\n provider: body.data.provider,\n model: body.data.model,\n });\n return c.json({ channel: await channels.getChannelStatus(user.id) });\n });\n\n app.delete(\"/telegram/llm-key/:provider\", async (c) => {\n const user = requireUser(c);\n await channels.deleteLlmKey(user.id, c.req.param(\"provider\"));\n return c.json({ channel: await channels.getChannelStatus(user.id) });\n });\n\n app.put(\"/telegram/default-project\", async (c) => {\n const user = requireUser(c);\n const body = defaultProjectSchema.safeParse(await c.req.json().catch(() => null));\n if (!body.success) throw badRequest(\"Body inválido\", \"invalid_body\");\n await channels.setDefaultProject(user.id, user.analyticsEnabled, body.data.projectId);\n return c.json({ channel: await channels.getChannelStatus(user.id) });\n });\n\n app.post(\"/telegram/disconnect\", async (c) => {\n const user = requireUser(c);\n return c.json(await channels.disconnectChannel(user.id, user.analyticsEnabled));\n });\n\n return app;\n}\n"}, "aab25e87a1ac906dbf3bf5050aaeb3961e2207b8": {"t": 1785773325.32414, "n": 2554, "h": "8568e43ce1739d03", "c": "// REST del canal Telegram: status, link-token, llm-key, default project, disconnect.\n\nimport { Hono } from \"hono\";\nimport { z } from \"zod\";\nimport { type AppEnv, requireUser } from \"./http.js\";\nimport { badRequest } from \"../services/errors.js\";\nimport * as channels from \"../services/channels.js\";\n\nconst llmKeySchema = z.object({\n apiKey: z.string().min(20),\n provider: z.enum([\"anthropic\", \"openai\", \"deepseek\"]).optional(),\n model: z.string().optional(),\n});\n\nconst defaultProjectSchema = z.object({\n projectId: z.string().nullable(),\n});\n\nconst linkTokenSchema = z.object({\n projectId: z.string().optional(),\n});\n\nexport function channelRoutes() {\n const app = new Hono();\n\n app.get(\"/telegram\", async (c) => {\n const user = requireUser(c);\n return c.json({ channel: await channels.getChannelStatus(user.id) });\n });\n\n app.post(\"/telegram/link-token\", async (c) => {\n const user = requireUser(c);\n const body = linkTokenSchema.safeParse(await c.req.json().catch(() => ({})));\n if (!body.success) throw badRequest(\"Body inválido\", \"invalid_body\");\n const result = await channels.createLinkToken(user.id, user.analyticsEnabled, body.data.projectId);\n return c.json(result, 201);\n });\n\n app.put(\"/telegram/llm-key\", async (c) => {\n const user = requireUser(c);\n const body = llmKeySchema.safeParse(await c.req.json().catch(() => null));\n if (!body.success) throw badRequest(\"API key LLM inválida\", \"invalid_body\");\n await channels.setLlmKey(user.id, user.analyticsEnabled, body.data.apiKey, {\n provider: body.data.provider,\n model: body.data.model,\n });\n return c.json({ channel: await channels.getChannelStatus(user.id) });\n });\n\n app.delete(\"/telegram/llm-key/:provider\", async (c) => {\n const user = requireUser(c);\n await channels.deleteLlmKey(user.id, c.req.param(\"provider\"));\n return c.json({ channel: await channels.getChannelStatus(user.id) });\n });\n\n app.put(\"/telegram/default-project\", async (c) => {\n const user = requireUser(c);\n const body = defaultProjectSchema.safeParse(await c.req.json().catch(() => null));\n if (!body.success) throw badRequest(\"Body inválido\", \"invalid_body\");\n await channels.setDefaultProject(user.id, user.analyticsEnabled, body.data.projectId);\n return c.json({ channel: await channels.getChannelStatus(user.id) });\n });\n\n app.post(\"/telegram/disconnect\", async (c) => {\n const user = requireUser(c);\n return c.json(await channels.disconnectChannel(user.id, user.analyticsEnabled));\n });\n\n return app;\n}\n"}, "6bf46d76b24e59299e7ef89ad794c74b60755b3d": {"t": 1785773326.4939303, "n": 2462, "h": "d690bf912048cf25", "c": "// REST del canal Telegram: status, link-token, llm-key, default project, disconnect.\n\nimport { Hono } from \"hono\";\nimport { z } from \"zod\";\nimport { type AppEnv, requireUser } from \"./http.js\";\nimport { badRequest } from \"../services/errors.js\";\nimport * as channels from \"../services/channels.js\";\n\nconst llmKeySchema = z.object({\n apiKey: z.string().min(20),\n provider: z.enum([\"anthropic\", \"openai\", \"deepseek\"]).optional(),\n model: z.string().optional(),\n});\n\nconst defaultProjectSchema = z.object({\n projectId: z.string().nullable(),\n});\n\nconst linkTokenSchema = z.object({\n projectId: z.string().optional(),\n});\n\nexport function channelRoutes() {\n const app = new Hono();\n\n app.get(\"/telegram\", async (c) => {\n const user = requireUser(c);\n return c.json({ channel: await channels.getChannelStatus(user.id) });\n });\n\n app.post(\"/telegram/link-token\", async (c) => {\n const user = requireUser(c);\n const body = linkTokenSchema.safeParse(await c.req.json().catch(() => ({})));\n if (!body.success) throw badRequest(\"Body inválido\", \"invalid_body\");\n const result = await channels.createLinkToken(user.id, body.data.projectId);\n return c.json(result, 201);\n });\n\n app.put(\"/telegram/llm-key\", async (c) => {\n const user = requireUser(c);\n const body = llmKeySchema.safeParse(await c.req.json().catch(() => null));\n if (!body.success) throw badRequest(\"API key LLM inválida\", \"invalid_body\");\n await channels.setLlmKey(user.id, body.data.apiKey, {\n provider: body.data.provider,\n model: body.data.model,\n });\n return c.json({ channel: await channels.getChannelStatus(user.id) });\n });\n\n app.delete(\"/telegram/llm-key/:provider\", async (c) => {\n const user = requireUser(c);\n await channels.deleteLlmKey(user.id, c.req.param(\"provider\"));\n return c.json({ channel: await channels.getChannelStatus(user.id) });\n });\n\n app.put(\"/telegram/default-project\", async (c) => {\n const user = requireUser(c);\n const body = defaultProjectSchema.safeParse(await c.req.json().catch(() => null));\n if (!body.success) throw badRequest(\"Body inválido\", \"invalid_body\");\n await channels.setDefaultProject(user.id, body.data.projectId);\n return c.json({ channel: await channels.getChannelStatus(user.id) });\n });\n\n app.post(\"/telegram/disconnect\", async (c) => {\n const user = requireUser(c);\n return c.json(await channels.disconnectChannel(user.id));\n });\n\n return app;\n}\n"}, "601386c65bfbd498812dae612f6824a094c45043": {"t": 1785773327.7229857, "n": 2222, "h": "e65c9ea54883c441", "c": "// REST del canal Telegram: status, link-token, llm-key, default project, disconnect.\n\nimport { Hono } from \"hono\";\nimport { z } from \"zod\";\nimport { type AppEnv, requireUser } from \"./http.js\";\nimport { badRequest } from \"../services/errors.js\";\nimport * as channels from \"../services/channels.js\";\n\nconst llmKeySchema = z.object({\n apiKey: z.string().min(20),\n provider: z.enum([\"anthropic\", \"openai\", \"deepseek\"]).optional(),\n model: z.string().optional(),\n});\n\nconst defaultProjectSchema = z.object({\n projectId: z.string().nullable(),\n});\n\nconst linkTokenSchema = z.object({\n projectId: z.string().optional(),\n});\n\nexport function channelRoutes() {\n const app = new Hono();\n\n app.get(\"/telegram\", async (c) => {\n const user = requireUser(c);\n return c.json({ channel: await channels.getChannelStatus(user.id) });\n });\n\n app.post(\"/telegram/link-token\", async (c) => {\n const user = requireUser(c);\n const body = linkTokenSchema.safeParse(await c.req.json().catch(() => ({})));\n if (!body.success) throw badRequest(\"Body inválido\", \"invalid_body\");\n const result = await channels.createLinkToken(user.id, body.data.projectId);\n return c.json(result, 201);\n });\n\n app.put(\"/telegram/llm-key\", async (c) => {\n const user = requireUser(c);\n const body = llmKeySchema.safeParse(await c.req.json().catch(() => null));\n if (!body.success) throw badRequest(\"API key LLM inválida\", \"invalid_body\");\n await channels.setLlmKey(user.id, body.data.apiKey, {\n provider: body.data.provider,\n model: body.data.model,\n });\n return c.json({ channel: await channels.getChannelStatus(user.id) });\n });\n\n app.put(\"/telegram/default-project\", async (c) => {\n const user = requireUser(c);\n const body = defaultProjectSchema.safeParse(await c.req.json().catch(() => null));\n if (!body.success) throw badRequest(\"Body inválido\", \"invalid_body\");\n await channels.setDefaultProject(user.id, body.data.projectId);\n return c.json({ channel: await channels.getChannelStatus(user.id) });\n });\n\n app.post(\"/telegram/disconnect\", async (c) => {\n const user = requireUser(c);\n return c.json(await channels.disconnectChannel(user.id));\n });\n\n return app;\n}\n"}}}}