feat(core): Add access modes for agent Telegram integration (no-changelog) (#30258)

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: yehorkardash <yehor.kardash@n8n.io>
This commit is contained in:
Eugene 2026-05-14 16:22:17 +02:00 committed by GitHub
parent 42887203fb
commit bc7075bfdb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
25 changed files with 1389 additions and 107 deletions

View File

@ -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.

View File

@ -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 {

View File

@ -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<typeof agentTelegramSettingsSchema>;
export const agentIntegrationSettingsSchema = z.union([agentTelegramSettingsSchema, z.undefined()]);
export type AgentIntegrationSettings = z.infer<typeof agentIntegrationSettingsSchema>;
// 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(),
}) {}

View File

@ -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';

View File

@ -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<CredentialsService>(),
chatIntegrationService = mock<ChatIntegrationService>(),
agentScheduleService = mock<AgentScheduleService>(),
agentRepository = mock<AgentRepository>(),
}: {
credentialsService?: jest.Mocked<CredentialsService>;
chatIntegrationService?: jest.Mocked<ChatIntegrationService>;
agentScheduleService?: jest.Mocked<AgentScheduleService>;
agentRepository?: jest.Mocked<AgentRepository>;
} = {}) {
const controller = new AgentsController(
mock<AgentsService>(),
mock<AgentsBuilderService>(),
credentialsService,
chatIntegrationService,
agentScheduleService,
agentRepository,
mock<AgentExecutionService>(),
mock<ChatIntegrationRegistry>(),
);
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>();
credentialsService.getCredentialsAUserCanUseInAWorkflow.mockResolvedValue([
{
id: 'cred-telegram',
name: 'Telegram Bot',
type: 'telegramApi',
scopes: [],
isManaged: false,
isGlobal: false,
isResolvable: true,
},
]);
const agentRepository = mock<AgentRepository>();
const agent = {
id: 'agent-1',
projectId: 'project-1',
publishedVersion: {},
integrations: [],
};
agentRepository.findByIdAndProjectId.mockResolvedValue(agent as never);
const chatIntegrationService = mock<ChatIntegrationService>();
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>();
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>();
chatIntegrationService.getStatus.mockReturnValue({
status: 'disconnected',
connections: 0,
integrations: [],
});
const agentScheduleService = mock<AgentScheduleService>();
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 }],
});
});
});

View File

@ -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',

View File

@ -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',

View File

@ -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<unknown> {
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<ComponentMapper>();
@ -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();
});
});
});

View File

@ -554,6 +554,29 @@ describe('ChatIntegrationService — multi-main role-aware behavior', () => {
});
});
it('publishes settings alongside a connect broadcast', async () => {
const publisher = mock<Publisher>();
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>();
publisher.publishCommand.mockRejectedValue(new Error('redis is down'));

View File

@ -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
// ---------------------------------------------------------------------------

View File

@ -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<unknown, unknown>) => 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;
}
/**

View File

@ -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<void> {
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<void> {
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<void> {
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 };
}
}

View File

@ -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();

View File

@ -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) {

View File

@ -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();

View File

@ -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;
};
/**

View File

@ -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.",

View File

@ -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('|')"
>
<button data-testid="stub-create-credential" @click="$emit('create')" />
<button
data-testid="stub-select-first-credential"
@click="$emit('update:modelValue', credentials[0]?.id)"
/>
</div>
`,
};
@ -128,11 +143,23 @@ const globalStubs = {
'<button v-bind="$attrs" :disabled="disabled || loading" @click="$emit(\'click\', $event)"><slot name="prefix" /><slot /></button>',
},
N8nCard: { template: '<article><slot name="header" /><slot /></article>' },
N8nCallout: { template: '<div v-bind="$attrs"><slot /></div>' },
N8nDialog: { template: '<div><slot /></div>' },
N8nHeading: { template: '<h2><slot /></h2>' },
N8nIcon: { template: '<i />' },
N8nOption: { template: '<option><slot /></option>' },
N8nSelect: { template: '<div><slot name="prefix" /><slot /></div>' },
N8nInput: {
props: ['modelValue'],
emits: ['update:modelValue'],
template:
'<input v-bind="$attrs" :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)" />',
},
N8nOption: { props: ['value', 'label'], template: '<option :value="value">{{ label }}</option>' },
N8nSelect: {
props: ['modelValue'],
emits: ['update:modelValue'],
template:
'<select v-bind="$attrs" :value="modelValue" @change="$emit(\'update:modelValue\', $event.target.value)"><slot name="prefix" /><slot /></select>',
},
N8nText: { template: '<span><slot /></span>' },
};
@ -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);
});
});

View File

@ -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<string>(props.data.initialTriggerType ?? '');
const {
statuses,
connectedCredentials,
integrationSettings,
loadingMap,
errorMessages,
errorIsConflict,
@ -76,6 +78,7 @@ const {
const selectedCredentials = ref<Record<string, string>>({});
const credentialsByType = ref<Record<string, AgentCredentialOption[]>>({});
const credentialsLoading = ref(false);
const settingsFormRef = ref<InstanceType<typeof AgentIntegrationSettingsForm>>();
// 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<boolean> {
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)"
/>
</div>
<N8nText
v-if="hasError(currentIntegration.type)"
:class="$style.errorText"
size="small"
>
{{ errorMessages[currentIntegration.type] }}
<a
v-if="
selectedCredentials[currentIntegration.type] &&
!errorIsConflict[currentIntegration.type]
"
:class="$style.link"
href="#"
@click.prevent="onEditCredential(currentIntegration.type)"
>{{ i18n.baseText('agents.builder.addTrigger.editCredential') }}</a
>
</N8nText>
</div>
<div v-else :class="$style.connectedSection">
@ -586,6 +573,32 @@ onMounted(async () => {
</N8nText>
</div>
<AgentIntegrationSettingsForm
ref="settingsFormRef"
:type="currentIntegration.type"
:disabled="isConnected(currentIntegration.type) || isLoading(currentIntegration.type)"
:connected="isConnected(currentIntegration.type)"
:saved-settings="integrationSettings[currentIntegration.type]"
/>
<N8nText
v-if="!isConnected(currentIntegration.type) && hasError(currentIntegration.type)"
:class="$style.errorText"
size="small"
>
{{ errorMessages[currentIntegration.type] }}
<a
v-if="
selectedCredentials[currentIntegration.type] &&
!errorIsConflict[currentIntegration.type]
"
:class="$style.link"
href="#"
@click.prevent="onEditCredential(currentIntegration.type)"
>{{ i18n.baseText('agents.builder.addTrigger.editCredential') }}</a
>
</N8nText>
<!-- Slack manifest reference material. Integration actions live
in the modal footer so they stay aligned with other modals. -->
<div v-if="currentIntegration.type === 'slack'" :class="$style.manifestSection">
@ -628,7 +641,8 @@ onMounted(async () => {
:disabled="
!selectedCredentials[currentIntegration.type] ||
isLoading(currentIntegration.type) ||
publishing
publishing ||
!!settingsFormRef?.validationError
"
:loading="isLoading(currentIntegration.type) || publishing"
size="small"

View File

@ -0,0 +1,53 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue';
import type { AgentIntegrationSettings, AgentTelegramIntegrationSettings } from '@n8n/api-types';
import { resolveSavedTelegramSettings } from '../utils/telegramAccessSettings';
import AgentTelegramAccessSettingsForm from './AgentTelegramAccessSettingsForm.vue';
const props = withDefaults(
defineProps<{
type: string;
disabled?: boolean;
connected?: boolean;
savedSettings?: AgentIntegrationSettings;
}>(),
{ disabled: false, connected: false, savedSettings: undefined },
);
const telegramFormRef = ref<InstanceType<typeof AgentTelegramAccessSettingsForm>>();
const telegramSavedSettings = computed<AgentTelegramIntegrationSettings | undefined>(() =>
resolveSavedTelegramSettings(props.savedSettings, props.connected),
);
const hasTelegramForm = computed(() => props.type === 'telegram');
const currentSettings = computed<AgentIntegrationSettings | undefined>(
() => telegramFormRef.value?.currentSettings,
);
const validationError = computed<string | null>(
() => telegramFormRef.value?.validationError ?? null,
);
const isDirty = computed<boolean>(() => telegramFormRef.value?.isDirty ?? false);
watch(
() => props.type,
() => {
telegramFormRef.value = undefined;
},
);
defineExpose({ currentSettings, validationError, isDirty });
</script>
<template>
<AgentTelegramAccessSettingsForm
v-if="hasTelegramForm"
ref="telegramFormRef"
:disabled="disabled"
:saved-settings="telegramSavedSettings"
/>
</template>

View File

@ -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<Record<string, string>>({});
const credentialsByType = ref<Record<string, AgentCredentialOption[]>>({});
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<string, InstanceType<typeof AgentIntegrationSettingsForm> | 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 () => {
>
<N8nText size="small">{{ config.noCredentialsMessage }}</N8nText>
</div>
<N8nText v-if="hasError(config.type)" :class="$style.errorText" size="small">
{{ errorMessages[config.type] }}
<a
v-if="selectedCredentials[config.type] && !errorIsConflict[config.type]"
:class="$style.link"
href="#"
@click.prevent="onEditCredential(config.type)"
>Edit credential</a
>
</N8nText>
<div :class="$style.actions">
<N8nButton
:disabled="!selectedCredentials[config.type] || isLoading(config.type)"
:loading="isLoading(config.type)"
size="small"
:data-testid="`${config.type}-connect-button`"
@click="onConnect(config.type)"
>
<N8nIcon icon="plug" :size="14" />
Connect
</N8nButton>
</div>
</div>
<div v-else :class="$style.connectedSection">
<N8nText size="small">
{{ config.connectedDescription }}
</N8nText>
</div>
<!-- Slack App Manifest (Slack only) -->
<template v-if="config.type === 'slack'">
<N8nText :class="$style.manifestHint" size="small">
Copy the app manifest and paste it into your Slack app's settings to configure events,
scopes, and interactivity.
<a :class="$style.link" href="#" @click.prevent="showManifest = true">View JSON</a>
</N8nText>
<N8nButton variant="outline" size="small" @click="copyManifest">
<N8nIcon :icon="copied ? 'check' : 'copy'" :size="14" />
{{ copied ? 'Copied' : 'Copy manifest' }}
</N8nButton>
</template>
<AgentIntegrationSettingsForm
:ref="getSettingsFormRef(config.type)"
:type="config.type"
:disabled="isConnected(config.type) || isLoading(config.type)"
:connected="isConnected(config.type)"
:saved-settings="integrationSettings[config.type]"
/>
<N8nText
v-if="!isConnected(config.type) && hasError(config.type)"
:class="$style.errorText"
size="small"
>
{{ errorMessages[config.type] }}
<a
v-if="selectedCredentials[config.type] && !errorIsConflict[config.type]"
:class="$style.link"
href="#"
@click.prevent="onEditCredential(config.type)"
>Edit credential</a
>
</N8nText>
<!-- Slack App Manifest (Slack only, shown when connected) -->
<template v-if="isConnected(config.type) && config.type === 'slack'">
<N8nText :class="$style.manifestHint" size="small">
Copy the app manifest and paste it into your Slack app's settings to configure events,
scopes, and interactivity.
<a :class="$style.link" href="#" @click.prevent="showManifest = true">View JSON</a>
</N8nText>
<N8nButton variant="outline" size="small" @click="copyManifest">
<N8nIcon :icon="copied ? 'check' : 'copy'" :size="14" />
{{ copied ? 'Copied' : 'Copy manifest' }}
</N8nButton>
</template>
<div v-if="!isConnected(config.type)" :class="$style.actions">
<N8nButton
:class="$style.actionButton"
variant="destructive"
:disabled="
!selectedCredentials[config.type] ||
isLoading(config.type) ||
!!settingsFormRefs[config.type]?.validationError
"
:loading="isLoading(config.type)"
size="small"
:data-testid="`${config.type}-disconnect-button`"
@click="onDisconnect(config.type)"
:data-testid="`${config.type}-connect-button`"
@click="onConnect(config.type)"
>
<N8nIcon icon="unlink" :size="14" />
Disconnect
<N8nIcon icon="plug" :size="14" />
Connect
</N8nButton>
<!-- Manifest modal (Slack only) -->
<N8nDialog
v-if="config.type === 'slack'"
:open="showManifest"
header="Slack App Manifest"
size="medium"
@update:open="showManifest = $event"
>
<pre :class="$style.manifestCode">{{ slackAppManifest }}</pre>
</N8nDialog>
</div>
<N8nButton
v-else
:class="$style.actionButton"
variant="destructive"
:loading="isLoading(config.type)"
size="small"
:data-testid="`${config.type}-disconnect-button`"
@click="onDisconnect(config.type)"
>
<N8nIcon icon="unlink" :size="14" />
Disconnect
</N8nButton>
<!-- Manifest modal (Slack only) -->
<N8nDialog
v-if="config.type === 'slack'"
:open="showManifest"
header="Slack App Manifest"
size="medium"
@update:open="showManifest = $event"
>
<pre :class="$style.manifestCode">{{ slackAppManifest }}</pre>
</N8nDialog>
</div>
</N8nCard>
</div>

View File

@ -0,0 +1,300 @@
<script setup lang="ts">
import { ref, computed, watch, nextTick } from 'vue';
import { N8nCallout, N8nIcon, N8nOption, N8nSelect, N8nText } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import type { AgentTelegramIntegrationSettings } from '@n8n/api-types';
import {
DEFAULT_TELEGRAM_PUBLIC_SETTINGS,
VALID_TELEGRAM_ENTRY_RE,
type TelegramSettingsValidationError,
} from '../utils/telegramAccessSettings';
const props = withDefaults(
defineProps<{
disabled?: boolean;
/**
* Saved settings pass `undefined` for fresh setup so the form defaults
* to private; for connected legacy integrations pass
* `DEFAULT_TELEGRAM_PUBLIC_SETTINGS` so the form starts public.
*/
savedSettings?: AgentTelegramIntegrationSettings;
}>(),
{ disabled: false, savedSettings: undefined },
);
const i18n = useI18n();
const accessMode = ref<AgentTelegramIntegrationSettings['accessMode']>(
props.savedSettings?.accessMode ?? 'private',
);
const entries = ref<string[]>(props.savedSettings?.allowedUsers.slice() ?? []);
const inputText = ref('');
const inputRef = ref<HTMLInputElement>();
watch(
() => props.savedSettings,
(saved) => {
if (!saved) return;
accessMode.value = saved.accessMode;
entries.value = saved.allowedUsers.slice();
inputText.value = '';
},
);
function finalizeInput() {
const raw = inputText.value.trim();
if (!raw) return;
const tokens = raw.split(/[\s,]+/).filter(Boolean);
const unique = new Set(entries.value);
for (const token of tokens) {
unique.add(token);
}
entries.value = [...unique];
inputText.value = '';
}
function removeEntry(index: number) {
entries.value = entries.value.filter((_, i) => i !== index);
void nextTick(() => inputRef.value?.focus());
}
function onKeydown(e: KeyboardEvent) {
if (e.key === ',' || e.key === ' ' || e.key === 'Enter') {
e.preventDefault();
finalizeInput();
}
if (e.key === 'Backspace' && inputText.value === '' && entries.value.length > 0) {
entries.value = entries.value.slice(0, -1);
}
}
function onPaste(e: ClipboardEvent) {
e.preventDefault();
const pasted = e.clipboardData?.getData('text') ?? '';
inputText.value += pasted;
finalizeInput();
}
function onContainerClick() {
inputRef.value?.focus();
}
const currentSettings = computed<AgentTelegramIntegrationSettings>(() => ({
accessMode: accessMode.value,
allowedUsers: [...new Set(entries.value.filter(Boolean))],
}));
const invalidEntries = computed<string[]>(() =>
entries.value.filter((entry) => !VALID_TELEGRAM_ENTRY_RE.test(entry)),
);
const validationError = computed<TelegramSettingsValidationError | null>(() => {
if (currentSettings.value.accessMode === 'public') return null;
if (invalidEntries.value.length > 0) return 'invalid';
if (entries.value.length === 0) return 'required';
return null;
});
const validationErrorText = computed<string>(() => {
if (validationError.value === 'invalid') {
return i18n.baseText('agents.builder.addTrigger.telegram.validation.invalid');
}
if (validationError.value === 'required') {
return i18n.baseText('agents.builder.addTrigger.telegram.validation.required');
}
return '';
});
const isDirty = computed<boolean>(() => {
const saved = props.savedSettings ?? DEFAULT_TELEGRAM_PUBLIC_SETTINGS;
const current = currentSettings.value;
if (current.accessMode !== saved.accessMode) return true;
if (current.allowedUsers.length !== saved.allowedUsers.length) return true;
return current.allowedUsers.some((entry, i) => entry !== saved.allowedUsers[i]);
});
defineExpose({ currentSettings, validationError, isDirty });
</script>
<template>
<div :class="$style.form">
<div :class="$style.field">
<N8nText size="small" bold>
{{ i18n.baseText('agents.builder.addTrigger.telegram.accessMode.label') }}
</N8nText>
<N8nSelect
v-model="accessMode"
size="medium"
:disabled="disabled"
data-testid="telegram-access-mode"
>
<N8nOption
value="private"
:label="i18n.baseText('agents.builder.addTrigger.telegram.accessMode.private')"
/>
<N8nOption
value="public"
:label="i18n.baseText('agents.builder.addTrigger.telegram.accessMode.public')"
/>
</N8nSelect>
</div>
<div v-if="accessMode === 'private'" :class="$style.field">
<N8nText size="small" bold>
{{ i18n.baseText('agents.builder.addTrigger.telegram.users.label') }}
</N8nText>
<div
:class="[$style.tagInput, { [$style.tagInputDisabled]: disabled }]"
data-testid="telegram-user-ids"
@click="onContainerClick"
>
<span
v-for="(entry, idx) in entries"
:key="entry + idx"
:class="[$style.badge, { [$style.badgeInvalid]: !VALID_TELEGRAM_ENTRY_RE.test(entry) }]"
>
{{ entry }}
<button
v-if="!disabled"
:class="$style.badgeRemove"
type="button"
:aria-label="'Remove ' + entry"
@click.stop="removeEntry(idx)"
>
<N8nIcon icon="x" size="small" />
</button>
</span>
<input
ref="inputRef"
v-model="inputText"
:class="$style.tagInputField"
:disabled="disabled"
:placeholder="
entries.length === 0
? i18n.baseText('agents.builder.addTrigger.telegram.users.placeholder')
: ''
"
@keydown="onKeydown"
@paste="onPaste"
@blur="finalizeInput"
/>
</div>
<N8nText
v-if="validationError"
:class="$style.error"
size="small"
data-testid="telegram-user-ids-error"
>
{{ validationErrorText }}
</N8nText>
</div>
<N8nCallout
v-else
:class="$style.warning"
theme="warning"
slim
data-testid="telegram-public-warning"
>
{{ i18n.baseText('agents.builder.addTrigger.telegram.public.warning') }}
</N8nCallout>
</div>
</template>
<style module lang="scss">
.form {
display: flex;
flex-direction: column;
gap: var(--spacing--xs);
}
.field {
display: flex;
flex-direction: column;
gap: var(--spacing--2xs);
}
.warning {
align-items: flex-start;
}
.error {
color: var(--color--danger);
}
.tagInput {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--spacing--4xs);
padding: var(--spacing--4xs) var(--spacing--3xs);
border: var(--border);
border-radius: var(--radius);
background-color: var(--input--color--background, var(--color--foreground--tint-2));
cursor: text;
min-height: 36px;
&:focus-within {
border-color: var(--color--primary);
}
}
.tagInputDisabled {
opacity: 0.5;
cursor: not-allowed;
}
.tagInputField {
flex: 1;
min-width: 80px;
border: none;
outline: none;
background: transparent;
font-size: var(--font-size--2xs);
color: var(--text-color);
padding: var(--spacing--4xs) 0;
&::placeholder {
color: var(--text-color--subtler);
}
}
.badge {
display: inline-flex;
align-items: center;
gap: var(--spacing--5xs);
height: var(--tag--height);
padding: var(--tag--padding);
line-height: var(--tag--line-height);
color: var(--tag--color--text);
background-color: var(--tag--color--background);
border: 1px solid var(--tag--border-color);
border-radius: var(--tag--radius);
font-size: var(--tag--font-size);
white-space: nowrap;
}
.badgeInvalid {
border-color: var(--color--danger);
color: var(--color--danger);
}
.badgeRemove {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
border: none;
background: transparent;
cursor: pointer;
color: inherit;
opacity: 0.6;
line-height: 1;
&:hover {
opacity: 1;
}
}
</style>

View File

@ -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 } : {}) },
);
};

View File

@ -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<Record<string, Status>>;
connectedCredentials: Ref<Record<string, string>>;
integrationSettings: Ref<Record<string, AgentIntegrationSettings | undefined>>;
loadingMap: Ref<Record<string, boolean>>;
errorMessages: Ref<Record<string, string>>;
errorIsConflict: Ref<Record<string, boolean>>;
@ -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<void> {
async function connect(
type: string,
credId: string,
settings?: AgentIntegrationSettings,
): Promise<void> {
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,

View File

@ -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(', ');
}