mirror of
https://github.com/n8n-io/n8n.git
synced 2026-06-01 01:07:04 +02:00
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:
parent
42887203fb
commit
bc7075bfdb
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}) {}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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 } : {}) },
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(', ');
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user