From bc7075bfdb70933b1ab180fca5f8bbf413429803 Mon Sep 17 00:00:00 2001 From: Eugene Date: Thu, 14 May 2026 16:22:17 +0200 Subject: [PATCH] feat(core): Add access modes for agent Telegram integration (no-changelog) (#30258) Co-authored-by: Claude Opus 4.7 Co-authored-by: yehorkardash --- packages/@n8n/agents/AGENTS.md | 4 + packages/@n8n/api-types/src/agents.ts | 4 + .../src/dto/agents/agent-integration.dto.ts | 42 +++ packages/@n8n/api-types/src/dto/index.ts | 9 +- .../__tests__/agents.controller.test.ts | 180 +++++++++++ .../agents/__tests__/from-json-config.test.ts | 68 ++++ .../src/modules/agents/agents.controller.ts | 51 ++- .../__tests__/agent-chat-bridge.test.ts | 161 +++++++++- .../chat-integration.service.test.ts | 23 ++ .../agents/integrations/agent-chat-bridge.ts | 20 +- .../integrations/agent-chat-integration.ts | 11 +- .../integrations/chat-integration.service.ts | 41 ++- .../__tests__/telegram-integration.test.ts | 59 ++++ .../platforms/telegram-integration.ts | 27 +- .../agents/json-config/integration-config.ts | 2 + .../src/scaling/pubsub/pubsub.event-map.ts | 8 +- .../frontend/@n8n/i18n/src/locales/en.json | 8 + .../AgentIntegrationCredentialPickers.test.ts | 125 +++++++- .../components/AgentAddTriggerModal.vue | 54 ++-- .../AgentIntegrationSettingsForm.vue | 53 ++++ .../components/AgentIntegrationsPanel.vue | 141 ++++---- .../AgentTelegramAccessSettingsForm.vue | 300 ++++++++++++++++++ .../agents/composables/useAgentApi.ts | 4 +- .../composables/useAgentIntegrationStatus.ts | 24 +- .../agents/utils/telegramAccessSettings.ts | 77 +++++ 25 files changed, 1389 insertions(+), 107 deletions(-) create mode 100644 packages/frontend/editor-ui/src/features/agents/components/AgentIntegrationSettingsForm.vue create mode 100644 packages/frontend/editor-ui/src/features/agents/components/AgentTelegramAccessSettingsForm.vue create mode 100644 packages/frontend/editor-ui/src/features/agents/utils/telegramAccessSettings.ts diff --git a/packages/@n8n/agents/AGENTS.md b/packages/@n8n/agents/AGENTS.md index f3bc63375d3..b509bce817c 100644 --- a/packages/@n8n/agents/AGENTS.md +++ b/packages/@n8n/agents/AGENTS.md @@ -134,3 +134,7 @@ pnpm build # rimraf dist && tsc -p tsconfig.build.json → dist/ pnpm typecheck # tsc --noEmit pnpm test # jest (unit) ``` + +## PR naming convention + +The Agents feature is not generally available yet, so any PRs related to the Agents package should have (no-changelog) in the title to avoid generating a changelog entry. diff --git a/packages/@n8n/api-types/src/agents.ts b/packages/@n8n/api-types/src/agents.ts index c0c205fcb8f..4b309e669b9 100644 --- a/packages/@n8n/api-types/src/agents.ts +++ b/packages/@n8n/api-types/src/agents.ts @@ -7,6 +7,8 @@ import { } from 'n8n-workflow'; import { z } from 'zod'; +import type { AgentIntegrationSettings } from './dto/agents/agent-integration.dto'; + /** * Describes a chat platform integration that agents can connect to. * Source of truth: the backend `ChatIntegrationRegistry`. @@ -62,6 +64,7 @@ export interface AgentCredentialIntegration { type: string; credentialId: string; credentialName: string; + settings?: AgentIntegrationSettings; } export interface AgentScheduleIntegration { @@ -82,6 +85,7 @@ export interface AgentScheduleConfig { export interface AgentIntegrationStatusEntry { type: string; credentialId?: string; + settings?: AgentIntegrationSettings; } export interface AgentIntegrationStatusResponse { diff --git a/packages/@n8n/api-types/src/dto/agents/agent-integration.dto.ts b/packages/@n8n/api-types/src/dto/agents/agent-integration.dto.ts index 71bcf08215a..0680894d362 100644 --- a/packages/@n8n/api-types/src/dto/agents/agent-integration.dto.ts +++ b/packages/@n8n/api-types/src/dto/agents/agent-integration.dto.ts @@ -2,7 +2,49 @@ import { z } from 'zod'; import { Z } from '../../zod-class'; +export const AGENT_TELEGRAM_ACCESS_MODES = ['private', 'public'] as const; + +export const agentTelegramSettingsSchema = z + .object({ + accessMode: z.enum(AGENT_TELEGRAM_ACCESS_MODES), + // allowedUsers holds both Telegram user IDs (numeric strings, e.g. "487257961") + // and usernames (alphanumeric + underscore, e.g. "@yokano" or "yokano"). Values + // are stored verbatim — the leading "@" is NOT stripped here so user intent is + // preserved. Normalization (stripping "@") happens only at access-check time in + // TelegramIntegration.isUserAllowed(). + allowedUsers: z + .array( + z + .string() + .trim() + .regex( + /^@?[a-zA-Z0-9_]+$/, + 'Enter a valid Telegram user ID (numbers only) or username (letters, numbers, underscores)', + ), + ) + .default([]) + .transform((items) => [...new Set(items)]), + }) + .strict() + .superRefine((settings, ctx) => { + if (settings.accessMode === 'private' && settings.allowedUsers.length === 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['allowedUsers'], + message: 'Add at least one Telegram user ID or username', + }); + } + }); + +export type AgentTelegramIntegrationSettings = z.infer; + +export const agentIntegrationSettingsSchema = z.union([agentTelegramSettingsSchema, z.undefined()]); + +export type AgentIntegrationSettings = z.infer; + +// TODO: discriminate settings by type of integration export class AgentIntegrationDto extends Z.class({ type: z.string().min(1), credentialId: z.string().min(1), + settings: agentIntegrationSettingsSchema.optional(), }) {} diff --git a/packages/@n8n/api-types/src/dto/index.ts b/packages/@n8n/api-types/src/dto/index.ts index 191ecf40e43..648e7caa9f0 100644 --- a/packages/@n8n/api-types/src/dto/index.ts +++ b/packages/@n8n/api-types/src/dto/index.ts @@ -248,7 +248,14 @@ export { agentSkillSchema, } from './agents/create-agent-skill.dto'; export { UpdateAgentSkillDto } from './agents/update-agent-skill.dto'; -export { AgentIntegrationDto } from './agents/agent-integration.dto'; +export { + AGENT_TELEGRAM_ACCESS_MODES, + AgentIntegrationDto, + agentIntegrationSettingsSchema, + agentTelegramSettingsSchema, + type AgentIntegrationSettings, + type AgentTelegramIntegrationSettings, +} from './agents/agent-integration.dto'; export { AgentChatMessageDto } from './agents/agent-chat-message.dto'; export { AgentBuildResumeDto } from './agents/agent-build-resume.dto'; diff --git a/packages/cli/src/modules/agents/__tests__/agents.controller.test.ts b/packages/cli/src/modules/agents/__tests__/agents.controller.test.ts index 1f0d85f86a7..1efe887657d 100644 --- a/packages/cli/src/modules/agents/__tests__/agents.controller.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agents.controller.test.ts @@ -3,6 +3,7 @@ import { Container } from '@n8n/di'; import { mock } from 'jest-mock-extended'; import type { CredentialsService } from '@/credentials/credentials.service'; +import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { NotFoundError } from '@/errors/response-errors/not-found.error'; import type { AgentsService } from '../agents.service'; @@ -28,6 +29,37 @@ const routeCases = Array.from(metadata.routes.entries()).map(([handlerName, rout route, })); +function makeController({ + credentialsService = mock(), + chatIntegrationService = mock(), + agentScheduleService = mock(), + agentRepository = mock(), +}: { + credentialsService?: jest.Mocked; + chatIntegrationService?: jest.Mocked; + agentScheduleService?: jest.Mocked; + agentRepository?: jest.Mocked; +} = {}) { + const controller = new AgentsController( + mock(), + mock(), + credentialsService, + chatIntegrationService, + agentScheduleService, + agentRepository, + mock(), + mock(), + ); + + return { + controller, + credentialsService, + chatIntegrationService, + agentScheduleService, + agentRepository, + }; +} + describe('AgentsController route access scopes', () => { it.each(routeCases)( '$handlerName is gated by a project-scoped agent:* check', @@ -109,4 +141,152 @@ describe('AgentsController integration credentials', () => { ); expect(chatIntegrationService.connect).not.toHaveBeenCalled(); }); + + it('requires Telegram settings when connecting Telegram', async () => { + const { controller, chatIntegrationService } = makeController(); + + await expect( + controller.connectIntegration( + { + params: { projectId: 'project-1' }, + user: { id: 'user-1' }, + } as never, + undefined as never, + 'agent-1', + { type: 'telegram', credentialId: 'cred-telegram' }, + ), + ).rejects.toThrow(BadRequestError); + + expect(chatIntegrationService.connect).not.toHaveBeenCalled(); + }); + + it('persists and broadcasts Telegram settings on connect', async () => { + const credentialsService = mock(); + credentialsService.getCredentialsAUserCanUseInAWorkflow.mockResolvedValue([ + { + id: 'cred-telegram', + name: 'Telegram Bot', + type: 'telegramApi', + scopes: [], + isManaged: false, + isGlobal: false, + isResolvable: true, + }, + ]); + + const agentRepository = mock(); + const agent = { + id: 'agent-1', + projectId: 'project-1', + publishedVersion: {}, + integrations: [], + }; + agentRepository.findByIdAndProjectId.mockResolvedValue(agent as never); + + const chatIntegrationService = mock(); + const { controller } = makeController({ + credentialsService, + chatIntegrationService, + agentRepository, + }); + const settings = { + type: 'telegram' as const, + accessMode: 'private' as const, + allowedUsers: ['123'], + }; + + await expect( + controller.connectIntegration( + { + params: { projectId: 'project-1' }, + user: { id: 'user-1' }, + } as never, + undefined as never, + 'agent-1', + { type: 'telegram', credentialId: 'cred-telegram', settings }, + ), + ).resolves.toEqual({ status: 'connected' }); + + expect(chatIntegrationService.connect).toHaveBeenCalledWith( + 'agent-1', + 'cred-telegram', + 'telegram', + 'user-1', + 'project-1', + { settings }, + ); + expect(agentRepository.save).toHaveBeenCalledWith({ + ...agent, + integrations: [ + { + type: 'telegram', + credentialId: 'cred-telegram', + credentialName: 'Telegram Bot', + settings, + }, + ], + }); + expect(chatIntegrationService.broadcastIntegrationChange).toHaveBeenCalledWith( + 'agent-1', + 'telegram', + 'cred-telegram', + 'connect', + settings, + ); + }); + + it('returns Telegram integrations from the persisted agent entry even when the live bridge is empty', async () => { + const settings = { + type: 'telegram' as const, + accessMode: 'private' as const, + allowedUsers: ['123'], + }; + const agentRepository = mock(); + agentRepository.findByIdAndProjectId.mockResolvedValue({ + id: 'agent-1', + projectId: 'project-1', + integrations: [ + { + type: 'telegram', + credentialId: 'cred-telegram', + credentialName: 'Telegram Bot', + settings, + }, + ], + } as never); + + // In-memory chat-service map is transiently empty (boot / reconnect / + // leader-takeover race). Status must still surface the integration + // from the persisted entry, otherwise the FE trigger chip flickers. + const chatIntegrationService = mock(); + chatIntegrationService.getStatus.mockReturnValue({ + status: 'disconnected', + connections: 0, + integrations: [], + }); + + const agentScheduleService = mock(); + agentScheduleService.getConfig.mockReturnValue({ + active: false, + cronExpression: '0 0 * * *', + wakeUpPrompt: 'tick', + }); + + const { controller } = makeController({ + agentRepository, + chatIntegrationService, + agentScheduleService, + }); + + await expect( + controller.integrationStatus( + { params: { projectId: 'project-1' } } as never, + undefined as never, + 'agent-1', + ), + ).resolves.toEqual({ + status: 'connected', + integrations: [{ type: 'telegram', credentialId: 'cred-telegram', settings }], + }); + }); }); diff --git a/packages/cli/src/modules/agents/__tests__/from-json-config.test.ts b/packages/cli/src/modules/agents/__tests__/from-json-config.test.ts index 24a060c7eb4..9a8fdc0fed2 100644 --- a/packages/cli/src/modules/agents/__tests__/from-json-config.test.ts +++ b/packages/cli/src/modules/agents/__tests__/from-json-config.test.ts @@ -713,6 +713,74 @@ describe('AgentJsonConfigSchema', () => { expect(() => AgentJsonConfigSchema.parse(config)).toThrow(); }); + it('validates Telegram private integration settings', () => { + const config = { + name: 'test', + model: 'anthropic/claude-sonnet-4-5', + credential: 'my-key', + instructions: '', + integrations: [ + { + type: 'telegram', + credentialId: 'cred-1', + credentialName: 'Telegram Bot', + settings: { + accessMode: 'private', + allowedUsers: ['123', '123', '456', 'john_doe123'], + }, + }, + ], + }; + + const parsed = AgentJsonConfigSchema.parse(config); + + expect(parsed.integrations?.[0]).toMatchObject({ + type: 'telegram', + settings: { + accessMode: 'private', + allowedUsers: ['123', '456', 'john_doe123'], + }, + }); + }); + + it('rejects Telegram private integration settings without valid user IDs', () => { + const config = { + name: 'test', + model: 'anthropic/claude-sonnet-4-5', + credential: 'my-key', + instructions: '', + integrations: [ + { + type: 'telegram', + credentialId: 'cred-1', + credentialName: 'Telegram Bot', + settings: { accessMode: 'private', allowedUsers: [] }, + }, + ], + }; + + expect(() => AgentJsonConfigSchema.parse(config)).toThrow(); + }); + + it('rejects Telegram integration settings with entries containing invalid characters', () => { + const config = { + name: 'test', + model: 'anthropic/claude-sonnet-4-5', + credential: 'my-key', + instructions: '', + integrations: [ + { + type: 'telegram', + credentialId: 'cred-1', + credentialName: 'Telegram Bot', + settings: { accessMode: 'private', allowedUsers: ['user name'] }, + }, + ], + }; + + expect(() => AgentJsonConfigSchema.parse(config)).toThrow(); + }); + it('parses an integrations array containing schedule + chat triggers', () => { const config = { name: 'test', diff --git a/packages/cli/src/modules/agents/agents.controller.ts b/packages/cli/src/modules/agents/agents.controller.ts index dc982ac5985..a5b1ab49a94 100644 --- a/packages/cli/src/modules/agents/agents.controller.ts +++ b/packages/cli/src/modules/agents/agents.controller.ts @@ -1,11 +1,13 @@ import { AGENT_SCHEDULE_TRIGGER_TYPE, type AgentBuilderMessagesResponse, + type AgentCredentialIntegration, type AgentIntegrationStatusResponse, type AgentPersistedMessageDto, type AgentSkill, type AgentScheduleConfig, type AgentSseEvent, + type AgentIntegrationSettings, type ChatIntegrationDescriptor, AgentBuildResumeDto, AgentChatMessageDto, @@ -34,6 +36,7 @@ import { randomUUID } from 'crypto'; import type { Request, Response } from 'express'; import { CredentialsService } from '@/credentials/credentials.service'; +import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { ConflictError } from '@/errors/response-errors/conflict.error'; import { NotFoundError } from '@/errors/response-errors/not-found.error'; @@ -103,6 +106,19 @@ export class AgentsController { private readonly chatIntegrationRegistry: ChatIntegrationRegistry, ) {} + private settingsForConnect( + integrationType: string, + settings: AgentIntegrationSettings | undefined, + ): AgentIntegrationSettings | undefined { + if (!settings) { + if (integrationType === 'telegram') { + throw new BadRequestError('Integration settings are required for telegram'); + } + return undefined; + } + return settings; + } + @Post('/') @ProjectScope('agent:create') async create( @@ -649,6 +665,7 @@ export class AgentsController { @Body payload: AgentIntegrationDto, ) { const { type, credentialId } = payload; + const settings = this.settingsForConnect(type, payload.settings); const agent = await this.agentRepository.findByIdAndProjectId(agentId, req.params.projectId); if (!agent) throw new NotFoundError(`Agent "${agentId}" not found`); if (!agent.publishedVersion) @@ -669,6 +686,7 @@ export class AgentsController { type, req.user.id, agent.projectId, + settings ? { settings } : {}, ); // Persist the integration reference on the agent @@ -676,10 +694,24 @@ export class AgentsController { const alreadyExists = existing.some( (i) => isAgentCredentialIntegration(i) && i.type === type && i.credentialId === credentialId, ); - if (!alreadyExists) { - agent.integrations = [...existing, { type, credentialId, credentialName: credential.name }]; - await this.agentRepository.save(agent); - } + const integration: AgentCredentialIntegration = { + type, + credentialId, + credentialName: credential.name, + ...(settings ? { settings } : {}), + }; + + // Replace existing integration or append a new one + agent.integrations = alreadyExists + ? existing.map((existingIntegration) => + isAgentCredentialIntegration(existingIntegration) && + existingIntegration.type === type && + existingIntegration.credentialId === credentialId + ? integration + : existingIntegration, + ) + : [...existing, integration]; + await this.agentRepository.save(agent); // Notify peer mains so they connect the integration too — without this // step inbound webhooks load-balanced to a follower would 404. @@ -688,6 +720,7 @@ export class AgentsController { type, credentialId, 'connect', + settings, ); return { status: 'connected' }; @@ -790,10 +823,16 @@ export class AgentsController { const agent = await this.agentRepository.findByIdAndProjectId(agentId, req.params.projectId); if (!agent) throw new NotFoundError(`Agent "${agentId}" not found`); - const chatStatus = this.chatIntegrationService.getStatus(agentId); + const chatIntegrations = (agent.integrations ?? []) + .filter(isAgentCredentialIntegration) + .map((i) => ({ + type: i.type, + credentialId: i.credentialId, + ...(i.settings ? { settings: i.settings } : {}), + })); const schedule = this.agentScheduleService.getConfig(agent); const scheduleIntegrations = schedule.active ? [{ type: AGENT_SCHEDULE_TRIGGER_TYPE }] : []; - const connectedIntegrations = [...chatStatus.integrations, ...scheduleIntegrations]; + const connectedIntegrations = [...chatIntegrations, ...scheduleIntegrations]; return { status: connectedIntegrations.length > 0 ? 'connected' : 'disconnected', diff --git a/packages/cli/src/modules/agents/integrations/__tests__/agent-chat-bridge.test.ts b/packages/cli/src/modules/agents/integrations/__tests__/agent-chat-bridge.test.ts index 61c2d8185c4..eb4f7225553 100644 --- a/packages/cli/src/modules/agents/integrations/__tests__/agent-chat-bridge.test.ts +++ b/packages/cli/src/modules/agents/integrations/__tests__/agent-chat-bridge.test.ts @@ -1,7 +1,9 @@ +import { agentTelegramSettingsSchema, type AgentIntegrationSettings } from '@n8n/api-types'; import type { StreamChunk } from '@n8n/agents'; +import type { Author } from 'chat'; import { Container } from '@n8n/di'; import { mock } from 'jest-mock-extended'; -import type { Logger } from 'n8n-workflow'; +import { UnexpectedError, type Logger } from 'n8n-workflow'; import { AgentChatBridge } from '../agent-chat-bridge'; import { @@ -90,6 +92,32 @@ class StreamingTestIntegration extends AgentChatIntegration { } } +// TODO: use real Telegram integration for testing +class TelegramTestIntegration extends AgentChatIntegration { + readonly type = 'telegram'; + readonly credentialTypes: string[] = []; + readonly supportedComponents: string[] = []; + readonly displayLabel = 'Telegram'; + readonly displayIcon = 'telegram'; + async createAdapter(_ctx: AgentChatIntegrationContext): Promise { + return {}; + } + isUserAllowed(author: Author, settings: AgentIntegrationSettings | undefined): boolean { + if (!settings) return true; + const validConfig = agentTelegramSettingsSchema.safeParse(settings); + if (!validConfig.success) { + throw new UnexpectedError( + `Invalid Telegram integration settings: ${validConfig.error.message}`, + ); + } + if (settings.accessMode === 'public') return true; + return settings.allowedUsers.some((allowed) => { + const normalized = allowed.startsWith('@') ? allowed.slice(1) : allowed; + return normalized === author.userId || normalized === author.userName; + }); + } +} + describe('AgentChatBridge — consumeStream', () => { let registry: ChatIntegrationRegistry; const componentMapper = mock(); @@ -99,6 +127,7 @@ describe('AgentChatBridge — consumeStream', () => { registry = new ChatIntegrationRegistry(); registry.register(new BufferingTestIntegration()); registry.register(new StreamingTestIntegration()); + registry.register(new TelegramTestIntegration()); Container.set(ChatIntegrationRegistry, registry); }); @@ -134,7 +163,7 @@ describe('AgentChatBridge — consumeStream', () => { 'test-buffered', ); - await handlers.mention!(thread, { text: 'hi', author: { userId: 'u1' } }); + await handlers.mention!(thread, { text: 'hi', author: { userId: 'u1', userName: 'user1' } }); expect(thread.post).toHaveBeenCalledTimes(1); expect(thread.post).toHaveBeenCalledWith({ markdown: 'Hello world' }); @@ -168,7 +197,7 @@ describe('AgentChatBridge — consumeStream', () => { 'test-buffered', ); - await handlers.mention!(thread, { text: 'hi', author: { userId: 'u1' } }); + await handlers.mention!(thread, { text: 'hi', author: { userId: 'u1', userName: 'user1' } }); expect(thread.post).toHaveBeenCalledTimes(3); expect(thread.post).toHaveBeenNthCalledWith(1, { markdown: 'Before suspend. ' }); @@ -194,7 +223,7 @@ describe('AgentChatBridge — consumeStream', () => { 'test-buffered', ); - await handlers.mention!(thread, { text: 'hi', author: { userId: 'u1' } }); + await handlers.mention!(thread, { text: 'hi', author: { userId: 'u1', userName: 'user1' } }); expect(thread.post).not.toHaveBeenCalled(); }); @@ -220,11 +249,133 @@ describe('AgentChatBridge — consumeStream', () => { 'test-streaming', ); - await handlers.mention!(thread, { text: 'hi', author: { userId: 'u1' } }); + await handlers.mention!(thread, { text: 'hi', author: { userId: 'u1', userName: 'user1' } }); expect(thread.post).toHaveBeenCalledTimes(1); const received = await drainIterable(thread.post.mock.calls[0][0]); expect(received).toBe('Hello world'); }); }); + + describe('Telegram access settings', () => { + it('silently ignores Telegram messages from users outside the private whitelist', async () => { + const { bot, handlers } = makeBot(); + const thread = makeThread(); + const agentExecutor = { + executeForChatPublished: jest.fn(() => + toStream([{ type: 'text-delta', id: 't1', delta: 'Hello' }]), + ), + resumeForChat: jest.fn(() => toStream([])), + }; + + new AgentChatBridge( + bot as unknown as ChatBotLike, + 'agent-1', + agentExecutor as never, + componentMapper, + logger, + 'project-1', + 'telegram', + { accessMode: 'private', allowedUsers: ['123'] }, + ); + + await handlers.mention!(thread, { + text: 'hi', + author: { userId: '999', userName: 'stranger' }, + }); + + expect(thread.subscribe).not.toHaveBeenCalled(); + expect(thread.post).not.toHaveBeenCalled(); + expect(agentExecutor.executeForChatPublished).not.toHaveBeenCalled(); + }); + + it('allows Telegram messages from users in the private whitelist', async () => { + const { bot, handlers } = makeBot(); + const thread = makeThread(); + const agentExecutor = { + executeForChatPublished: jest.fn(() => + toStream([{ type: 'text-delta', id: 't1', delta: 'Hello' }]), + ), + resumeForChat: jest.fn(() => toStream([])), + }; + + new AgentChatBridge( + bot as unknown as ChatBotLike, + 'agent-1', + agentExecutor as never, + componentMapper, + logger, + 'project-1', + 'telegram', + { accessMode: 'private', allowedUsers: ['123'] }, + ); + + await handlers.mention!(thread, { + text: 'hi', + author: { userId: '123', userName: 'alloweduser' }, + }); + + expect(thread.subscribe).toHaveBeenCalledTimes(1); + expect(agentExecutor.executeForChatPublished).toHaveBeenCalledTimes(1); + }); + + it('allows legacy Telegram integrations without settings', async () => { + const { bot, handlers } = makeBot(); + const thread = makeThread(); + const agentExecutor = { + executeForChatPublished: jest.fn(() => + toStream([{ type: 'text-delta', id: 't1', delta: 'Hello' }]), + ), + resumeForChat: jest.fn(() => toStream([])), + }; + + new AgentChatBridge( + bot as unknown as ChatBotLike, + 'agent-1', + agentExecutor as never, + componentMapper, + logger, + 'project-1', + 'telegram', + ); + + await handlers.mention!(thread, { + text: 'hi', + author: { userId: '999', userName: 'anyuser' }, + }); + + expect(agentExecutor.executeForChatPublished).toHaveBeenCalledTimes(1); + }); + + it('silently ignores Telegram actions from users outside the private whitelist', async () => { + const { bot, handlers } = makeBot(); + const thread = makeThread(); + const agentExecutor = { + executeForChatPublished: jest.fn(() => toStream([])), + resumeForChat: jest.fn(() => toStream([])), + }; + + new AgentChatBridge( + bot as unknown as ChatBotLike, + 'agent-1', + agentExecutor as never, + componentMapper, + logger, + 'project-1', + 'telegram', + { accessMode: 'private', allowedUsers: ['123'] }, + ); + + await handlers.action!({ + actionId: 'run-1:tool-1', + value: JSON.stringify({ response: 'yes' }), + thread, + threadId: thread.id, + user: { userId: '999', userName: 'stranger' }, + }); + + expect(agentExecutor.resumeForChat).not.toHaveBeenCalled(); + expect(thread.post).not.toHaveBeenCalled(); + }); + }); }); diff --git a/packages/cli/src/modules/agents/integrations/__tests__/chat-integration.service.test.ts b/packages/cli/src/modules/agents/integrations/__tests__/chat-integration.service.test.ts index 6e01edc20e7..a484a73a3e2 100644 --- a/packages/cli/src/modules/agents/integrations/__tests__/chat-integration.service.test.ts +++ b/packages/cli/src/modules/agents/integrations/__tests__/chat-integration.service.test.ts @@ -554,6 +554,29 @@ describe('ChatIntegrationService — multi-main role-aware behavior', () => { }); }); + it('publishes settings alongside a connect broadcast', async () => { + const publisher = mock(); + const { service } = buildServiceWith({ multiMainEnabled: true, publisher }); + const settings = { + type: 'telegram' as const, + accessMode: 'private' as const, + allowedUsers: ['123'], + }; + + await service.broadcastIntegrationChange('a1', 'telegram', 'c1', 'connect', settings); + + expect(publisher.publishCommand).toHaveBeenCalledWith({ + command: 'agent-chat-integration-changed', + payload: { + agentId: 'a1', + type: 'telegram', + credentialId: 'c1', + action: 'connect', + settings, + }, + }); + }); + it('swallows publisher failures so the user-facing flow keeps succeeding', async () => { const publisher = mock(); publisher.publishCommand.mockRejectedValue(new Error('redis is down')); diff --git a/packages/cli/src/modules/agents/integrations/agent-chat-bridge.ts b/packages/cli/src/modules/agents/integrations/agent-chat-bridge.ts index 7910d3f2009..9ebe5577c35 100644 --- a/packages/cli/src/modules/agents/integrations/agent-chat-bridge.ts +++ b/packages/cli/src/modules/agents/integrations/agent-chat-bridge.ts @@ -1,6 +1,8 @@ import type { AgentMessage, StreamChunk } from '@n8n/agents'; +import type { AgentIntegrationSettings } from '@n8n/api-types'; import { Container } from '@n8n/di'; -import type { ActionEvent, Chat, Message, Thread } from 'chat'; +import type { ActionEvent, Chat, Message, Thread, Author } from 'chat'; +import { UnexpectedError } from 'n8n-workflow'; import type { Logger } from 'n8n-workflow'; import type { AgentsService } from '../agents.service'; @@ -85,8 +87,13 @@ export class AgentChatBridge { private readonly logger: Logger, private readonly n8nProjectId: string, private readonly integrationType: string, + private readonly integrationSettings?: AgentIntegrationSettings, ) { - this.integration = Container.get(ChatIntegrationRegistry).get(integrationType); + const integration = Container.get(ChatIntegrationRegistry).get(integrationType); + if (!integration) { + throw new UnexpectedError(`Unknown integration type: ${integrationType}`); + } + this.integration = integration; if (this.integration?.needsShortCallbackData) { this.callbackStore = new CallbackStore(); } @@ -106,6 +113,7 @@ export class AgentChatBridge { logger: Logger, n8nProjectId: string, integrationType: string, + integrationSettings?: AgentIntegrationSettings, ): AgentChatBridge { const agentExecutor: AgentExecutor = { async *executeForChatPublished({ memory, agentId: aid, message, integrationType }) { @@ -129,6 +137,7 @@ export class AgentChatBridge { logger, n8nProjectId, integrationType, + integrationSettings, ); } @@ -139,6 +148,7 @@ export class AgentChatBridge { private registerHandlers(): void { this.chat.onNewMention(async (thread, message) => { try { + if (!this.canUserAccess(message.author)) return; await thread.subscribe(); await this.executeAndStream(thread, message); } catch (error) { @@ -148,6 +158,7 @@ export class AgentChatBridge { this.chat.onSubscribedMessage(async (thread, message) => { try { + if (!this.canUserAccess(message.author)) return; await this.executeAndStream(thread, message); } catch (error) { await this.postErrorToThread(thread, error); @@ -156,6 +167,7 @@ export class AgentChatBridge { this.chat.onAction(async (event) => { try { + if (!this.canUserAccess(event.user)) return; await this.handleAction(event); } catch (error) { await this.postErrorToThread(event.thread, error); @@ -168,6 +180,10 @@ export class AgentChatBridge { this.callbackStore?.dispose(); } + private canUserAccess(author: Author): boolean { + return this.integration?.isUserAllowed?.(author, this.integrationSettings) ?? true; + } + // --------------------------------------------------------------------------- // Thread ID resolution — single place to apply per-platform formatting // --------------------------------------------------------------------------- diff --git a/packages/cli/src/modules/agents/integrations/agent-chat-integration.ts b/packages/cli/src/modules/agents/integrations/agent-chat-integration.ts index 344c179ed6c..2f7f64efa35 100644 --- a/packages/cli/src/modules/agents/integrations/agent-chat-integration.ts +++ b/packages/cli/src/modules/agents/integrations/agent-chat-integration.ts @@ -1,5 +1,6 @@ +import type { AgentIntegrationSettings } from '@n8n/api-types'; import { Service } from '@n8n/di'; -import type { Thread } from 'chat'; +import type { Thread, Author } from 'chat'; import type { SuspendComponent } from './component-mapper'; @@ -125,6 +126,14 @@ export abstract class AgentChatIntegration { fromSdk: (thread: Thread) => string; toSdk: (threadId: string) => string; }; + + /** + * Optional per-user authorisation check called on every inbound mention, + * subscribed message, and action before the bridge subscribes / executes. + * Default (no implementation): allow. Telegram uses this to enforce the + * Private-mode allowlist. + */ + isUserAllowed?(author: Author, settings: AgentIntegrationSettings | undefined): boolean; } /** diff --git a/packages/cli/src/modules/agents/integrations/chat-integration.service.ts b/packages/cli/src/modules/agents/integrations/chat-integration.service.ts index f8e1b4dd2b9..9aec1404865 100644 --- a/packages/cli/src/modules/agents/integrations/chat-integration.service.ts +++ b/packages/cli/src/modules/agents/integrations/chat-integration.service.ts @@ -2,6 +2,7 @@ import { isAgentCredentialIntegration, type AgentCredentialIntegration, type AgentIntegrationStatusResponse, + type AgentIntegrationSettings, } from '@n8n/api-types'; import { Logger } from '@n8n/backend-common'; import { GlobalConfig } from '@n8n/config'; @@ -13,8 +14,8 @@ import { InstanceSettings } from 'n8n-core'; import { CredentialsFinderService } from '@/credentials/credentials-finder.service'; import { CredentialsService } from '@/credentials/credentials.service'; -import type { PubSubCommandMap } from '@/scaling/pubsub/pubsub.event-map'; import { Publisher } from '@/scaling/pubsub/publisher.service'; +import type { PubSubCommandMap } from '@/scaling/pubsub/pubsub.event-map'; import { UrlService } from '@/services/url.service'; import { AgentChatBridge } from './agent-chat-bridge'; @@ -53,6 +54,11 @@ interface ChatAgentConnection { bridge: AgentChatBridge; } +interface ConnectOptions { + skipExternalHooks?: boolean; + settings?: AgentIntegrationSettings; +} + /** * Manages per-agent Chat SDK instances and their lifecycle. * @@ -89,12 +95,16 @@ export class ChatIntegrationService { type: string, credentialId: string, action: 'connect' | 'disconnect', + settings?: AgentIntegrationSettings, ): Promise { if (!this.globalConfig.multiMainSetup.enabled) return; try { + const payload = settings + ? { agentId, type, credentialId, action, settings } + : { agentId, type, credentialId, action }; await this.publisher.publishCommand({ command: 'agent-chat-integration-changed', - payload: { agentId, type, credentialId, action }, + payload, }); } catch (error) { this.logger.warn( @@ -133,7 +143,7 @@ export class ChatIntegrationService { integrationType: string, userId: string, projectId: string, - options: { skipExternalHooks?: boolean } = {}, + options: ConnectOptions = {}, ): Promise { const key = this.connectionKey(agentId, integrationType, credentialId); @@ -195,6 +205,7 @@ export class ChatIntegrationService { this.logger, projectId, integrationType, + options.settings, ); // Initialize the Chat instance (connects adapters, state adapter, etc.) @@ -351,7 +362,9 @@ export class ChatIntegrationService { integration.type, userId, agent.projectId, + integration.settings ? { settings: integration.settings } : undefined, ); + connected = true; break; } catch (error) { @@ -369,6 +382,7 @@ export class ChatIntegrationService { integration.type, integration.credentialId, 'connect', + integration.settings, ); } else { this.logger.warn( @@ -478,6 +492,7 @@ export class ChatIntegrationService { // state so a stampede of reconnecting mains doesn't trip remote rate // limits. const skipExternalHooks = !this.instanceSettings.isLeader; + const options = this.connectOptionsFor(integration, skipExternalHooks); // Try each project member until one succeeds — the first user may not // have access to the integration credential. @@ -490,7 +505,7 @@ export class ChatIntegrationService { integration.type, userId, agent.projectId, - { skipExternalHooks }, + options, ); connected = true; break; @@ -524,7 +539,7 @@ export class ChatIntegrationService { async handleIntegrationChanged( payload: PubSubCommandMap['agent-chat-integration-changed'], ): Promise { - const { agentId, type, credentialId, action } = payload; + const { agentId, type, credentialId, action, settings: integrationSettings } = payload; if (action === 'disconnect') { await this.disconnect(agentId, type, credentialId); @@ -559,9 +574,10 @@ export class ChatIntegrationService { // Telegram setWebhook). We only need local state here — skipping // the hooks also avoids racing the originator into Telegram's 1/sec // rate limit. - await this.connect(agentId, credentialId, type, userId, agent.projectId, { - skipExternalHooks: true, - }); + const options: ConnectOptions = integrationSettings + ? { skipExternalHooks: true, settings: integrationSettings } + : { skipExternalHooks: true }; + await this.connect(agentId, credentialId, type, userId, agent.projectId, options); return; } catch (error) { this.logger.debug( @@ -629,4 +645,13 @@ export class ChatIntegrationService { const base = this.urlService.getWebhookBaseUrl(); return `${base}rest/projects/${projectId}/agents/v2/${agentId}/webhooks/${platform}`; } + + private connectOptionsFor( + integration: AgentCredentialIntegration, + skipExternalHooks: boolean, + ): ConnectOptions { + return integration.settings + ? { skipExternalHooks, settings: integration.settings } + : { skipExternalHooks }; + } } diff --git a/packages/cli/src/modules/agents/integrations/platforms/__tests__/telegram-integration.test.ts b/packages/cli/src/modules/agents/integrations/platforms/__tests__/telegram-integration.test.ts index e9e6d635213..83d5fc21bd8 100644 --- a/packages/cli/src/modules/agents/integrations/platforms/__tests__/telegram-integration.test.ts +++ b/packages/cli/src/modules/agents/integrations/platforms/__tests__/telegram-integration.test.ts @@ -12,6 +12,7 @@ import type { AgentRepository } from '../../../repositories/agent.repository'; import type { AgentChatIntegrationContext } from '../../agent-chat-integration'; import { loadTelegramAdapter } from '../../esm-loader'; import { TelegramIntegration } from '../telegram-integration'; +import type { Author } from 'chat'; jest.mock('../../esm-loader', () => ({ loadTelegramAdapter: jest.fn(), @@ -149,6 +150,64 @@ describe('TelegramIntegration.requiresLeader', () => { expect(integration.requiresLeader()).toBe(false); }); }); +describe('TelegramIntegration.isUserAllowed', () => { + const { integration } = makeIntegration(); + + it('allows everyone in public mode', () => { + expect( + integration.isUserAllowed({ userId: '999', userName: 'someuser' } as Author, { + accessMode: 'public', + allowedUsers: [], + }), + ).toBe(true); + }); + + it('allows everyone for legacy connections without saved settings', () => { + expect( + integration.isUserAllowed({ userId: '999', userName: 'someuser' } as Author, undefined), + ).toBe(true); + }); + + it('accepts a whitelisted user by numeric ID in private mode', () => { + expect( + integration.isUserAllowed({ userId: '123', userName: 'someuser' } as Author, { + accessMode: 'private', + allowedUsers: ['123', '456'], + }), + ).toBe(true); + }); + + it('accepts a whitelisted user by username in private mode', () => { + expect( + integration.isUserAllowed({ userId: '999', userName: 'john_doe123' } as Author, { + accessMode: 'private', + allowedUsers: ['john_doe123', '456'], + }), + ).toBe(true); + }); + + it('rejects a user whose ID and username are both absent from the allowlist', () => { + expect( + integration.isUserAllowed({ userId: '999', userName: 'stranger' } as Author, { + accessMode: 'private', + allowedUsers: ['123', 'john_doe123'], + }), + ).toBe(false); + }); + + it('throws UnexpectedError when settings type does not match telegram', () => { + expect(() => + integration.isUserAllowed( + { userId: '123', userName: 'user' } as Author, + { + type: 'slack', + accessMode: 'private', + allowedUsers: ['123'], + } as never, + ), + ).toThrow(); + }); +}); describe('TelegramIntegration secret token', () => { const createTelegramAdapter = jest.fn(); diff --git a/packages/cli/src/modules/agents/integrations/platforms/telegram-integration.ts b/packages/cli/src/modules/agents/integrations/platforms/telegram-integration.ts index 8adbe99f8ee..953fb2c6e7b 100644 --- a/packages/cli/src/modules/agents/integrations/platforms/telegram-integration.ts +++ b/packages/cli/src/modules/agents/integrations/platforms/telegram-integration.ts @@ -1,8 +1,10 @@ +import { agentTelegramSettingsSchema, type AgentIntegrationSettings } from '@n8n/api-types'; import { Logger } from '@n8n/backend-common'; import { Service } from '@n8n/di'; -import type { Thread } from 'chat'; +import type { Thread, Author } from 'chat'; import { createHmac } from 'crypto'; import { InstanceSettings } from 'n8n-core'; +import { UnexpectedError } from 'n8n-workflow'; import { ConflictError } from '@/errors/response-errors/conflict.error'; import { UrlService } from '@/services/url.service'; @@ -123,6 +125,29 @@ export class TelegramIntegration extends AgentChatIntegration { this.logger.info(`[TelegramIntegration] Webhook registered: ${webhookUrl}`); } + /** + * Enforce the Private-mode allowlist. Public mode (or legacy connections + * without saved settings) accepts every Telegram user; Private mode only + * accepts senders whose numeric user ID or username appears in `allowedUsers`. + * Stored values may carry a leading "@" (saved verbatim from user input), so + * they are normalized by stripping "@" before comparison. The SDK delivers + * both userId and userName without "@". + */ + isUserAllowed(author: Author, settings: AgentIntegrationSettings | undefined): boolean { + if (!settings) return true; + const validConfig = agentTelegramSettingsSchema.safeParse(settings); + if (!validConfig.success) { + throw new UnexpectedError( + `Invalid Telegram integration settings: ${validConfig.error.message}`, + ); + } + if (settings.accessMode === 'public') return true; + return settings.allowedUsers.some((allowed) => { + const normalized = allowed.startsWith('@') ? allowed.slice(1) : allowed; + return normalized === author.userId || normalized === author.userName; + }); + } + normalizeComponents(components: SuspendComponent[]): SuspendComponent[] { const normalized: SuspendComponent[] = []; for (const c of components) { diff --git a/packages/cli/src/modules/agents/json-config/integration-config.ts b/packages/cli/src/modules/agents/json-config/integration-config.ts index bbe5de31f57..90f135738bd 100644 --- a/packages/cli/src/modules/agents/json-config/integration-config.ts +++ b/packages/cli/src/modules/agents/json-config/integration-config.ts @@ -1,3 +1,4 @@ +import { agentIntegrationSettingsSchema } from '@n8n/api-types'; import { z } from 'zod'; import { isValidCronExpression } from '../integrations/cron-validation'; @@ -24,6 +25,7 @@ export const AgentCredentialIntegrationSchema = z }), credentialId: z.string().min(1), credentialName: z.string().min(1), + settings: agentIntegrationSettingsSchema.optional(), }) .strict(); diff --git a/packages/cli/src/scaling/pubsub/pubsub.event-map.ts b/packages/cli/src/scaling/pubsub/pubsub.event-map.ts index 9bfe307789c..32e0b356448 100644 --- a/packages/cli/src/scaling/pubsub/pubsub.event-map.ts +++ b/packages/cli/src/scaling/pubsub/pubsub.event-map.ts @@ -1,4 +1,9 @@ -import type { ChatHubMessageStatus, PushMessage, WorkerStatus } from '@n8n/api-types'; +import type { + AgentIntegrationSettings, + ChatHubMessageStatus, + PushMessage, + WorkerStatus, +} from '@n8n/api-types'; import type { IWorkflowBase, WorkflowActivateMode } from 'n8n-workflow'; export type PubSubCommandMap = { @@ -198,6 +203,7 @@ export type PubSubCommandMap = { type: string; credentialId: string; action: 'connect' | 'disconnect'; + settings?: AgentIntegrationSettings; }; /** diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index 6c6e1d420eb..06a9c973420 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -6119,6 +6119,14 @@ "agents.builder.addTrigger.slack.hideJson": "Hide JSON", "agents.builder.addTrigger.slack.copyManifest": "Copy manifest", "agents.builder.addTrigger.slack.manifestTitle": "Slack App Manifest", + "agents.builder.addTrigger.telegram.accessMode.label": "Access mode", + "agents.builder.addTrigger.telegram.accessMode.private": "Private", + "agents.builder.addTrigger.telegram.accessMode.public": "Public", + "agents.builder.addTrigger.telegram.public.warning": "Any Telegram user will be able to chat with this agent.", + "agents.builder.addTrigger.telegram.users.label": "Restrict to users", + "agents.builder.addTrigger.telegram.users.placeholder": "Type a user ID or username and press Space", + "agents.builder.addTrigger.telegram.validation.invalid": "Enter valid Telegram user IDs or usernames.", + "agents.builder.addTrigger.telegram.validation.required": "Add at least one Telegram user ID or username.", "agents.builder.addTrigger.helpText.slack": "Connect a Slack bot credential to allow this agent to receive and respond to Slack messages.", "agents.builder.addTrigger.helpText.telegram": "Connect a Telegram bot credential to allow this agent to receive and respond to Telegram messages.", "agents.builder.addTrigger.helpText.linear": "Connect a Linear API credential to let this agent respond to comments in Linear issues. Point a Linear webhook at the URL below and paste its signing secret into the credential.", diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentIntegrationCredentialPickers.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentIntegrationCredentialPickers.test.ts index add25b67985..40360c7d997 100644 --- a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentIntegrationCredentialPickers.test.ts +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentIntegrationCredentialPickers.test.ts @@ -4,6 +4,7 @@ import { mount, flushPromises } from '@vue/test-utils'; import AgentAddTriggerModal from '../components/AgentAddTriggerModal.vue'; import AgentIntegrationsPanel from '../components/AgentIntegrationsPanel.vue'; +import { clearAgentIntegrationStatusCache } from '../composables/useAgentIntegrationStatus'; const { fetchAllCredentialsForWorkflow, @@ -39,6 +40,16 @@ const slackIntegration = { noCredentialsMessage: 'No Slack credentials found.', }; +const telegramIntegration = { + type: 'telegram', + label: 'Telegram', + icon: 'telegram', + description: 'Connect Telegram', + connectedDescription: 'Connected to Telegram', + credentialTypes: ['telegramApi'], + noCredentialsMessage: 'No Telegram credentials found.', +}; + vi.mock('@n8n/i18n', () => ({ useI18n: () => ({ baseText: (key: string) => key }), i18n: { baseText: (key: string) => key }, @@ -81,7 +92,7 @@ vi.mock('../composables/useAgentApi', () => ({ vi.mock('../composables/useAgentIntegrationsCatalog', () => ({ useAgentIntegrationsCatalog: () => ({ - catalog: { value: [slackIntegration] }, + catalog: { value: [slackIntegration, telegramIntegration] }, ensureLoaded, }), })); @@ -110,6 +121,10 @@ const AgentCredentialSelectStub = { :data-options="credentials.map((credential) => credential.name).join('|')" > ', }, N8nCard: { template: '
' }, + N8nCallout: { template: '
' }, N8nDialog: { template: '
' }, N8nHeading: { template: '

' }, N8nIcon: { template: '' }, - N8nOption: { template: '' }, - N8nSelect: { template: '
' }, + N8nInput: { + props: ['modelValue'], + emits: ['update:modelValue'], + template: + '', + }, + N8nOption: { props: ['value', 'label'], template: '' }, + N8nSelect: { + props: ['modelValue'], + emits: ['update:modelValue'], + template: + '', + }, N8nText: { template: '' }, }; @@ -146,10 +173,12 @@ describe('agent integration credential picker usage', () => { }; projectState.personalProject = null; projectState.myProjects = []; + clearAgentIntegrationStatusCache('project-1', 'agent-1'); getIntegrationStatus.mockResolvedValue({ integrations: [] }); - ensureLoaded.mockResolvedValue([slackIntegration]); + ensureLoaded.mockResolvedValue([slackIntegration, telegramIntegration]); fetchAllCredentialsForWorkflow.mockResolvedValue([ { id: 'cred-1', name: 'Workspace Slack', type: 'slackOAuth2Api' }, + { id: 'cred-telegram', name: 'Telegram Bot', type: 'telegramApi' }, ]); }); @@ -242,4 +271,92 @@ describe('agent integration credential picker usage', () => { wrapper.find('[data-testid="agent-credential-select-stub"]').attributes('data-can-create'), ).toBe('false'); }); + + it('defaults Telegram setup to private mode and requires a user ID before connecting', async () => { + const wrapper = mount(AgentIntegrationsPanel, { + props: { + projectId: 'project-1', + agentId: 'agent-1', + agentName: 'Agent', + isPublished: true, + focusType: 'telegram', + }, + global: { stubs: globalStubs }, + }); + await flushPromises(); + + expect(wrapper.find('[data-testid="telegram-user-ids"]').exists()).toBe(true); + expect(wrapper.find('[data-testid="telegram-public-warning"]').exists()).toBe(false); + expect( + wrapper.find('[data-testid="telegram-connect-button"]').attributes('disabled'), + ).toBeDefined(); + + await wrapper.find('[data-testid="stub-select-first-credential"]').trigger('click'); + const tagInput = wrapper.find('[data-testid="telegram-user-ids"] input'); + await tagInput.setValue('123, 123, 456'); + await tagInput.trigger('blur'); + await wrapper.find('[data-testid="telegram-connect-button"]').trigger('click'); + await flushPromises(); + + expect(connectIntegration).toHaveBeenCalledWith( + {}, + 'project-1', + 'agent-1', + 'telegram', + 'cred-telegram', + { accessMode: 'private', allowedUsers: ['123', '456'] }, + ); + }); + + it('renders the Telegram public warning for legacy connected integrations without settings', async () => { + getIntegrationStatus.mockResolvedValue({ + integrations: [{ type: 'telegram', credentialId: 'cred-telegram' }], + }); + + const wrapper = mount(AgentIntegrationsPanel, { + props: { + projectId: 'project-1', + agentId: 'agent-1', + agentName: 'Agent', + isPublished: true, + focusType: 'telegram', + }, + global: { stubs: globalStubs }, + }); + await flushPromises(); + + expect(wrapper.find('[data-testid="telegram-public-warning"]').text()).toBe( + 'agents.builder.addTrigger.telegram.public.warning', + ); + expect(wrapper.find('[data-testid="telegram-user-ids"]').exists()).toBe(false); + }); + + it('disables the Telegram form when connected so users must disconnect to edit', async () => { + getIntegrationStatus.mockResolvedValue({ + integrations: [ + { + type: 'telegram', + credentialId: 'cred-telegram', + settings: { accessMode: 'private', allowedUsers: ['123'] }, + }, + ], + }); + + const wrapper = mount(AgentIntegrationsPanel, { + props: { + projectId: 'project-1', + agentId: 'agent-1', + agentName: 'Agent', + isPublished: true, + focusType: 'telegram', + }, + global: { stubs: globalStubs }, + }); + await flushPromises(); + + const userIds = wrapper.find('[data-testid="telegram-user-ids"]'); + expect(userIds.exists()).toBe(true); + expect(userIds.find('input').attributes('disabled')).toBeDefined(); + expect(wrapper.find('[data-testid="telegram-telegram-settings-save"]').exists()).toBe(false); + }); }); diff --git a/packages/frontend/editor-ui/src/features/agents/components/AgentAddTriggerModal.vue b/packages/frontend/editor-ui/src/features/agents/components/AgentAddTriggerModal.vue index ea5034003ff..a9c0519115b 100644 --- a/packages/frontend/editor-ui/src/features/agents/components/AgentAddTriggerModal.vue +++ b/packages/frontend/editor-ui/src/features/agents/components/AgentAddTriggerModal.vue @@ -26,6 +26,7 @@ import { useAgentConfirmationModal } from '../composables/useAgentConfirmationMo import type { AgentResource } from '../types'; import AgentScheduleTriggerCard from './AgentScheduleTriggerCard.vue'; import AgentCredentialSelect, { type AgentCredentialOption } from './AgentCredentialSelect.vue'; +import AgentIntegrationSettingsForm from './AgentIntegrationSettingsForm.vue'; const props = defineProps<{ modalName: string; @@ -64,6 +65,7 @@ const selectedTriggerType = ref(props.data.initialTriggerType ?? ''); const { statuses, connectedCredentials, + integrationSettings, loadingMap, errorMessages, errorIsConflict, @@ -76,6 +78,7 @@ const { const selectedCredentials = ref>({}); const credentialsByType = ref>({}); const credentialsLoading = ref(false); +const settingsFormRef = ref>(); // Track credentials that existed before the user opened the "new credential" // modal, keyed by trigger type. After the modal closes we diff against the @@ -347,10 +350,12 @@ async function ensurePublished(): Promise { async function onConnect(type: string) { const credId = selectedCredentials.value[type]; if (!credId) return; + if (settingsFormRef.value?.validationError) return; + const settings = settingsFormRef.value?.currentSettings; const published = await ensurePublished(); if (!published) return; try { - await connect(type, credId); + await connect(type, credId, settings); const triggers = computeConnectedTriggers(); props.data.onTriggerAdded({ triggerType: type, triggers }); emitConnectedTriggers(); @@ -560,24 +565,6 @@ onMounted(async () => { @click="onEditCredential(currentIntegration.type)" /> - - - {{ errorMessages[currentIntegration.type] }} - {{ i18n.baseText('agents.builder.addTrigger.editCredential') }} -
@@ -586,6 +573,32 @@ onMounted(async () => {
+ + + + {{ errorMessages[currentIntegration.type] }} + {{ i18n.baseText('agents.builder.addTrigger.editCredential') }} + +
@@ -628,7 +641,8 @@ onMounted(async () => { :disabled=" !selectedCredentials[currentIntegration.type] || isLoading(currentIntegration.type) || - publishing + publishing || + !!settingsFormRef?.validationError " :loading="isLoading(currentIntegration.type) || publishing" size="small" diff --git a/packages/frontend/editor-ui/src/features/agents/components/AgentIntegrationSettingsForm.vue b/packages/frontend/editor-ui/src/features/agents/components/AgentIntegrationSettingsForm.vue new file mode 100644 index 00000000000..9fcafdf0465 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/agents/components/AgentIntegrationSettingsForm.vue @@ -0,0 +1,53 @@ + + + diff --git a/packages/frontend/editor-ui/src/features/agents/components/AgentIntegrationsPanel.vue b/packages/frontend/editor-ui/src/features/agents/components/AgentIntegrationsPanel.vue index c93923b195a..62b9e52e54a 100644 --- a/packages/frontend/editor-ui/src/features/agents/components/AgentIntegrationsPanel.vue +++ b/packages/frontend/editor-ui/src/features/agents/components/AgentIntegrationsPanel.vue @@ -11,6 +11,7 @@ import { getResourcePermissions } from '@n8n/permissions'; import { useAgentIntegrationStatus } from '../composables/useAgentIntegrationStatus'; import AgentScheduleTriggerCard from './AgentScheduleTriggerCard.vue'; import AgentCredentialSelect, { type AgentCredentialOption } from './AgentCredentialSelect.vue'; +import AgentIntegrationSettingsForm from './AgentIntegrationSettingsForm.vue'; const props = withDefaults( defineProps<{ @@ -101,6 +102,7 @@ const integrationConfigs: IntegrationConfig[] = [ const { statuses, connectedCredentials, + integrationSettings, loadingMap, errorMessages, errorIsConflict, @@ -114,6 +116,19 @@ const { const selectedCredentials = ref>({}); const credentialsByType = ref>({}); const credentialsLoading = ref(false); + +// One ref per integration type — keyed by config.type so each card's form is +// read and validated independently. +const settingsFormRefs = ref< + Record | null> +>({}); +function getSettingsFormRef(type: string) { + return (el: unknown) => { + settingsFormRefs.value[type] = (el ?? null) as InstanceType< + typeof AgentIntegrationSettingsForm + > | null; + }; +} const copied = ref(false); const showManifest = ref(false); @@ -288,8 +303,10 @@ async function fetchCredentials() { async function onConnect(type: string) { const credId = selectedCredentials.value[type]; if (!credId) return; + if (settingsFormRefs.value[type]?.validationError) return; + const settings = settingsFormRefs.value[type]?.currentSettings; try { - await connect(type, credId); + await connect(type, credId, settings); const triggers = computeConnectedTriggers(); emit('trigger-added', { triggerType: type, triggers }); emitConnectedTriggers(); @@ -435,73 +452,89 @@ onMounted(async () => { > {{ config.noCredentialsMessage }}
- - - {{ errorMessages[config.type] }} - Edit credential - - -
- - - Connect - -
{{ config.connectedDescription }} +
- - + + + {{ errorMessages[config.type] }} + Edit credential + + + + + +
- - Disconnect + + Connect - - - -
{{ slackAppManifest }}
-
+ + + Disconnect + + + + +
{{ slackAppManifest }}
+
diff --git a/packages/frontend/editor-ui/src/features/agents/components/AgentTelegramAccessSettingsForm.vue b/packages/frontend/editor-ui/src/features/agents/components/AgentTelegramAccessSettingsForm.vue new file mode 100644 index 00000000000..37f7962ca9d --- /dev/null +++ b/packages/frontend/editor-ui/src/features/agents/components/AgentTelegramAccessSettingsForm.vue @@ -0,0 +1,300 @@ + + + + + diff --git a/packages/frontend/editor-ui/src/features/agents/composables/useAgentApi.ts b/packages/frontend/editor-ui/src/features/agents/composables/useAgentApi.ts index dab8a8ce23d..c174417de26 100644 --- a/packages/frontend/editor-ui/src/features/agents/composables/useAgentApi.ts +++ b/packages/frontend/editor-ui/src/features/agents/composables/useAgentApi.ts @@ -5,6 +5,7 @@ import type { AgentSkill, AgentSkillMutationResponse, AgentScheduleConfig, + AgentIntegrationSettings, ChatIntegrationDescriptor, } from '@n8n/api-types'; import { makeRestApiRequest } from '@n8n/rest-api-client'; @@ -75,12 +76,13 @@ export const connectIntegration = async ( agentId: string, type: string, credentialId: string, + settings?: AgentIntegrationSettings, ): Promise<{ status: string }> => { return await makeRestApiRequest( context, 'POST', `/projects/${projectId}/agents/v2/${agentId}/integrations/connect`, - { type, credentialId }, + { type, credentialId, ...(settings ? { settings } : {}) }, ); }; diff --git a/packages/frontend/editor-ui/src/features/agents/composables/useAgentIntegrationStatus.ts b/packages/frontend/editor-ui/src/features/agents/composables/useAgentIntegrationStatus.ts index 5ee08966dba..239198ef740 100644 --- a/packages/frontend/editor-ui/src/features/agents/composables/useAgentIntegrationStatus.ts +++ b/packages/frontend/editor-ui/src/features/agents/composables/useAgentIntegrationStatus.ts @@ -1,5 +1,5 @@ import { ref, type Ref } from 'vue'; -import type { AgentIntegrationStatusEntry } from '@n8n/api-types'; +import type { AgentIntegrationStatusEntry, AgentIntegrationSettings } from '@n8n/api-types'; import { ResponseError } from '@n8n/rest-api-client'; import { useRootStore } from '@n8n/stores/useRootStore'; @@ -10,6 +10,7 @@ type Status = 'connected' | 'disconnected' | 'unknown'; interface AgentIntegrationStatusState { statuses: Ref>; connectedCredentials: Ref>; + integrationSettings: Ref>; loadingMap: Ref>; errorMessages: Ref>; errorIsConflict: Ref>; @@ -31,6 +32,7 @@ function getOrCreate(projectId: string, agentId: string): AgentIntegrationStatus state = { statuses: ref({}), connectedCredentials: ref({}), + integrationSettings: ref({}), loadingMap: ref({}), errorMessages: ref({}), errorIsConflict: ref({}), @@ -54,11 +56,13 @@ function applyStatus( for (const type of integrationTypes) { state.statuses.value[type] = 'disconnected'; state.connectedCredentials.value[type] = ''; + state.integrationSettings.value[type] = undefined; } for (const integration of integrations) { state.statuses.value[integration.type] = 'connected'; state.connectedCredentials.value[integration.type] = typeof integration.credentialId === 'string' ? integration.credentialId : ''; + state.integrationSettings.value[integration.type] = integration.settings; } } @@ -102,16 +106,28 @@ export function useAgentIntegrationStatus(projectId: string, agentId: string) { await state.fetchInFlight; } - async function connect(type: string, credId: string): Promise { + async function connect( + type: string, + credId: string, + settings?: AgentIntegrationSettings, + ): Promise { state.loadingMap.value[type] = true; state.errorMessages.value[type] = ''; state.errorIsConflict.value[type] = false; try { - await connectIntegration(rootStore.restApiContext, projectId, agentId, type, credId); + await connectIntegration( + rootStore.restApiContext, + projectId, + agentId, + type, + credId, + settings, + ); // Reflect the change in the shared reactive state immediately so the // other consumer re-renders without waiting for a round-trip refetch. state.statuses.value[type] = 'connected'; state.connectedCredentials.value[type] = credId; + state.integrationSettings.value[type] = settings; } catch (e: unknown) { const msg = e instanceof Error @@ -133,6 +149,7 @@ export function useAgentIntegrationStatus(projectId: string, agentId: string) { await disconnectIntegration(rootStore.restApiContext, projectId, agentId, type, credId); state.statuses.value[type] = 'disconnected'; state.connectedCredentials.value[type] = ''; + state.integrationSettings.value[type] = undefined; } finally { state.loadingMap.value[type] = false; } @@ -145,6 +162,7 @@ export function useAgentIntegrationStatus(projectId: string, agentId: string) { return { statuses: state.statuses, connectedCredentials: state.connectedCredentials, + integrationSettings: state.integrationSettings, loadingMap: state.loadingMap, errorMessages: state.errorMessages, errorIsConflict: state.errorIsConflict, diff --git a/packages/frontend/editor-ui/src/features/agents/utils/telegramAccessSettings.ts b/packages/frontend/editor-ui/src/features/agents/utils/telegramAccessSettings.ts new file mode 100644 index 00000000000..8990364d893 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/agents/utils/telegramAccessSettings.ts @@ -0,0 +1,77 @@ +import type { AgentIntegrationSettings, AgentTelegramIntegrationSettings } from '@n8n/api-types'; + +export const DEFAULT_TELEGRAM_PUBLIC_SETTINGS = { + accessMode: 'public', + allowedUsers: [], +} satisfies AgentTelegramIntegrationSettings; + +/** + * Resolve the form's "saved" state for a Telegram integration. Returns the + * stored settings when present, the legacy public default for connected + * integrations missing settings, and `undefined` for unconnected setups so the + * form starts in private mode. + */ +export function resolveSavedTelegramSettings( + settings: AgentIntegrationSettings | undefined, + connected: boolean, +): AgentTelegramIntegrationSettings | undefined { + if (!connected) return undefined; + return settings ?? DEFAULT_TELEGRAM_PUBLIC_SETTINGS; +} + +export type TelegramSettingsValidationError = 'required' | 'invalid'; + +// Matches a Telegram user ID (numeric) or username (letters, numbers, underscores), +// optionally prefixed with "@". +export const VALID_TELEGRAM_ENTRY_RE = /^@?[a-zA-Z0-9_]+$/; + +export function parseTelegramUsersInput(input: string): { + allowedUsers: string[]; + invalidUsers: string[]; +} { + const rawEntries = input + .split(',') + .map((entry) => entry.trim()) + .filter(Boolean); + + const validEntries: string[] = []; + const invalidEntries: string[] = []; + + for (const entry of rawEntries) { + if (VALID_TELEGRAM_ENTRY_RE.test(entry)) { + validEntries.push(entry); + } else { + invalidEntries.push(entry); + } + } + + return { + allowedUsers: [...new Set(validEntries)], + invalidUsers: [...new Set(invalidEntries)], + }; +} + +export function createTelegramSettings( + accessMode: AgentTelegramIntegrationSettings['accessMode'], + usersInput: string, +): AgentTelegramIntegrationSettings { + const { allowedUsers } = parseTelegramUsersInput(usersInput); + return { accessMode, allowedUsers }; +} + +export function validateTelegramSettings( + settings: AgentTelegramIntegrationSettings, + usersInput: string, +): TelegramSettingsValidationError | null { + if (settings.accessMode === 'public') return null; + + const { allowedUsers, invalidUsers } = parseTelegramUsersInput(usersInput); + if (invalidUsers.length > 0) return 'invalid'; + if (allowedUsers.length === 0) return 'required'; + + return null; +} + +export function serializeTelegramUsers(users: string[]): string { + return users.join(', '); +}