feat: default new configs to multi-agent format (#261)
* feat: default new configs to multi-agent format Onboarding and non-interactive config generation now emit the agents[] array format instead of the legacy agent/channels/features flat structure. This makes adding a second agent a simple array append rather than a config format migration. Existing legacy configs continue to work -- normalizeAgents() handles both formats at runtime. Written by Cameron and Letta Code "The future is already here -- it's just not very evenly distributed." -- William Gibson * test: add save/load roundtrip tests for agents[] config format Tests the actual YAML serialization path: - agents[] written without legacy agent/channels at top level - roundtrip through save + load + normalizeAgents preserves all fields - global fields (providers, transcription) stay at top level Written by Cameron and Letta Code "Programs must be written for people to read, and only incidentally for machines to execute." -- Abelson & Sussman * feat: eagerly create agent during onboarding When the user picks "Create new agent", the agent is now created immediately (with spinner) rather than deferred to first message. The agent ID is written directly to lettabot.yaml, making the config file the single source of truth. Creates the agent with system prompt, memory blocks, selected model, display name, skills, and disables tool approvals -- same setup that bot.ts previously did lazily on first message. Graceful fallback: if creation fails, falls back to lazy creation. Written by Cameron and Letta Code "Make it work, make it right, make it fast." -- Kent Beck
This commit is contained in:
151
src/config/io.test.ts
Normal file
151
src/config/io.test.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { mkdtempSync, existsSync, readFileSync, rmSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import YAML from 'yaml';
|
||||
import { saveConfig, loadConfig } from './io.js';
|
||||
import { normalizeAgents } from './types.js';
|
||||
import type { LettaBotConfig } from './types.js';
|
||||
|
||||
describe('saveConfig with agents[] format', () => {
|
||||
let tmpDir: string;
|
||||
let configPath: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = mkdtempSync(join(tmpdir(), 'lettabot-config-test-'));
|
||||
configPath = join(tmpDir, 'lettabot.yaml');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('should write agents[] config without legacy agent/channels at top level', () => {
|
||||
const config = {
|
||||
server: { mode: 'cloud' as const, apiKey: 'test-key' },
|
||||
agents: [{
|
||||
name: 'TestBot',
|
||||
id: 'agent-abc123',
|
||||
channels: {
|
||||
telegram: { enabled: true, token: 'tg-token', dmPolicy: 'pairing' as const },
|
||||
},
|
||||
features: {
|
||||
cron: true,
|
||||
heartbeat: { enabled: true, intervalMin: 30 },
|
||||
},
|
||||
}],
|
||||
};
|
||||
|
||||
saveConfig(config, configPath);
|
||||
|
||||
const raw = readFileSync(configPath, 'utf-8');
|
||||
const parsed = YAML.parse(raw);
|
||||
|
||||
// Should have agents[] at top level
|
||||
expect(parsed.agents).toHaveLength(1);
|
||||
expect(parsed.agents[0].name).toBe('TestBot');
|
||||
expect(parsed.agents[0].channels.telegram.token).toBe('tg-token');
|
||||
|
||||
// Should NOT have legacy agent/channels at top level
|
||||
expect(parsed.agent).toBeUndefined();
|
||||
expect(parsed.channels).toBeUndefined();
|
||||
expect(parsed.features).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should roundtrip agents[] config through save and loadConfig+normalizeAgents', () => {
|
||||
const config = {
|
||||
server: { mode: 'cloud' as const, apiKey: 'test-key' },
|
||||
agents: [{
|
||||
name: 'MyBot',
|
||||
id: 'agent-xyz',
|
||||
channels: {
|
||||
telegram: { enabled: true, token: 'tg-123', dmPolicy: 'open' as const },
|
||||
whatsapp: { enabled: true, selfChat: true, dmPolicy: 'pairing' as const },
|
||||
},
|
||||
features: {
|
||||
cron: false,
|
||||
heartbeat: { enabled: true, intervalMin: 15 },
|
||||
},
|
||||
}],
|
||||
transcription: {
|
||||
provider: 'openai' as const,
|
||||
apiKey: 'whisper-key',
|
||||
},
|
||||
};
|
||||
|
||||
saveConfig(config, configPath);
|
||||
|
||||
// loadConfig reads from resolveConfigPath(), so we need to load manually
|
||||
// and merge with defaults the same way loadConfig does
|
||||
const raw = readFileSync(configPath, 'utf-8');
|
||||
const parsed = YAML.parse(raw) as Partial<LettaBotConfig>;
|
||||
const loaded: LettaBotConfig = {
|
||||
server: { mode: 'cloud', ...parsed.server },
|
||||
agent: { name: 'LettaBot', ...parsed.agent },
|
||||
channels: { ...parsed.channels },
|
||||
...parsed,
|
||||
};
|
||||
|
||||
// normalizeAgents should pick up agents[] and ignore defaults
|
||||
const agents = normalizeAgents(loaded);
|
||||
|
||||
expect(agents).toHaveLength(1);
|
||||
expect(agents[0].name).toBe('MyBot');
|
||||
expect(agents[0].id).toBe('agent-xyz');
|
||||
expect(agents[0].channels.telegram?.token).toBe('tg-123');
|
||||
expect(agents[0].channels.whatsapp?.selfChat).toBe(true);
|
||||
expect(agents[0].features?.heartbeat?.intervalMin).toBe(15);
|
||||
|
||||
// Global fields should survive
|
||||
expect(loaded.transcription?.apiKey).toBe('whisper-key');
|
||||
});
|
||||
|
||||
it('should always include agent id in agents[] (onboarding contract)', () => {
|
||||
// After onboarding, agent ID should always be present in the config.
|
||||
// This test documents the contract: new configs have the ID eagerly set.
|
||||
const config = {
|
||||
server: { mode: 'cloud' as const, apiKey: 'test-key' },
|
||||
agents: [{
|
||||
name: 'LettaBot',
|
||||
id: 'agent-eagerly-created',
|
||||
channels: {
|
||||
telegram: { enabled: true, token: 'tg-token' },
|
||||
},
|
||||
features: { cron: false, heartbeat: { enabled: false } },
|
||||
}],
|
||||
};
|
||||
|
||||
saveConfig(config, configPath);
|
||||
|
||||
const raw = readFileSync(configPath, 'utf-8');
|
||||
const parsed = YAML.parse(raw);
|
||||
|
||||
expect(parsed.agents[0].id).toBe('agent-eagerly-created');
|
||||
});
|
||||
|
||||
it('should preserve providers at top level, not inside agents', () => {
|
||||
const config = {
|
||||
server: { mode: 'cloud' as const, apiKey: 'test-key' },
|
||||
agents: [{
|
||||
name: 'TestBot',
|
||||
channels: {},
|
||||
features: { cron: false, heartbeat: { enabled: false } },
|
||||
}],
|
||||
providers: [{
|
||||
id: 'anthropic',
|
||||
name: 'anthropic',
|
||||
type: 'anthropic',
|
||||
apiKey: 'sk-ant-test',
|
||||
}],
|
||||
};
|
||||
|
||||
saveConfig(config, configPath);
|
||||
|
||||
const raw = readFileSync(configPath, 'utf-8');
|
||||
const parsed = YAML.parse(raw);
|
||||
|
||||
expect(parsed.providers).toHaveLength(1);
|
||||
expect(parsed.providers[0].name).toBe('anthropic');
|
||||
expect(parsed.agents[0].providers).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -81,7 +81,7 @@ export function loadConfig(): LettaBotConfig {
|
||||
/**
|
||||
* Save config to YAML file
|
||||
*/
|
||||
export function saveConfig(config: LettaBotConfig, path?: string): void {
|
||||
export function saveConfig(config: Partial<LettaBotConfig> & Pick<LettaBotConfig, 'server'>, path?: string): void {
|
||||
const configPath = path || resolveConfigPath();
|
||||
|
||||
// Ensure directory exists
|
||||
@@ -285,7 +285,7 @@ export function applyConfigToEnv(config: LettaBotConfig): void {
|
||||
/**
|
||||
* Create BYOK providers on Letta Cloud
|
||||
*/
|
||||
export async function syncProviders(config: LettaBotConfig): Promise<void> {
|
||||
export async function syncProviders(config: Partial<LettaBotConfig> & Pick<LettaBotConfig, 'server'>): Promise<void> {
|
||||
if (config.server.mode !== 'cloud' || !config.server.apiKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -374,4 +374,37 @@ describe('normalizeAgents', () => {
|
||||
expect(agents[0].displayName).toBe('💜 Signo');
|
||||
expect(agents[1].displayName).toBe('👾 DevOps');
|
||||
});
|
||||
|
||||
it('should normalize onboarding-generated agents[] config (no legacy agent/channels)', () => {
|
||||
// This matches the shape that onboarding now writes: agents[] at top level,
|
||||
// with no legacy agent/channels/features fields.
|
||||
const config = {
|
||||
server: { mode: 'cloud' as const },
|
||||
agents: [{
|
||||
name: 'LettaBot',
|
||||
id: 'agent-abc123',
|
||||
channels: {
|
||||
telegram: { enabled: true, token: 'tg-token', dmPolicy: 'pairing' as const },
|
||||
whatsapp: { enabled: true, selfChat: true },
|
||||
},
|
||||
features: {
|
||||
cron: true,
|
||||
heartbeat: { enabled: true, intervalMin: 30 },
|
||||
},
|
||||
}],
|
||||
// loadConfig() merges defaults for agent/channels, so they'll exist at runtime
|
||||
agent: { name: 'LettaBot' },
|
||||
channels: {},
|
||||
} as LettaBotConfig;
|
||||
|
||||
const agents = normalizeAgents(config);
|
||||
|
||||
expect(agents).toHaveLength(1);
|
||||
expect(agents[0].name).toBe('LettaBot');
|
||||
expect(agents[0].id).toBe('agent-abc123');
|
||||
expect(agents[0].channels.telegram?.token).toBe('tg-token');
|
||||
expect(agents[0].channels.whatsapp?.enabled).toBe(true);
|
||||
expect(agents[0].features?.cron).toBe(true);
|
||||
expect(agents[0].features?.heartbeat?.intervalMin).toBe(30);
|
||||
});
|
||||
});
|
||||
|
||||
239
src/onboard.ts
239
src/onboard.ts
@@ -7,7 +7,7 @@ import { resolve } from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import * as p from '@clack/prompts';
|
||||
import { saveConfig, syncProviders } from './config/index.js';
|
||||
import type { LettaBotConfig, ProviderConfig } from './config/types.js';
|
||||
import type { AgentConfig, LettaBotConfig, ProviderConfig } from './config/types.js';
|
||||
import { isLettaCloudUrl } from './utils/server.js';
|
||||
import { CHANNELS, getChannelHint, isSignalCliInstalled, setupTelegram, setupSlack, setupDiscord, setupWhatsApp, setupSignal } from './channels/setup.js';
|
||||
|
||||
@@ -116,81 +116,86 @@ function readConfigFromEnv(existingConfig: any): any {
|
||||
async function saveConfigFromEnv(config: any, configPath: string): Promise<void> {
|
||||
const { saveConfig } = await import('./config/index.js');
|
||||
|
||||
const lettabotConfig: LettaBotConfig = {
|
||||
const lettabotConfig: Partial<LettaBotConfig> & Pick<LettaBotConfig, 'server'> = {
|
||||
server: {
|
||||
mode: isLettaCloudUrl(config.baseUrl) ? 'cloud' : 'selfhosted',
|
||||
baseUrl: config.baseUrl,
|
||||
apiKey: config.apiKey,
|
||||
},
|
||||
agent: {
|
||||
id: config.agentId,
|
||||
agents: [{
|
||||
name: config.agentName,
|
||||
// model is configured on the Letta agent server-side, not saved to config
|
||||
},
|
||||
channels: {
|
||||
telegram: config.telegram.enabled ? {
|
||||
enabled: true,
|
||||
token: config.telegram.botToken,
|
||||
dmPolicy: config.telegram.dmPolicy,
|
||||
allowedUsers: config.telegram.allowedUsers,
|
||||
groupDebounceSec: config.telegram.groupDebounceSec,
|
||||
groupPollIntervalMin: config.telegram.groupPollIntervalMin,
|
||||
instantGroups: config.telegram.instantGroups,
|
||||
listeningGroups: config.telegram.listeningGroups,
|
||||
} : { enabled: false },
|
||||
|
||||
slack: config.slack.enabled ? {
|
||||
enabled: true,
|
||||
botToken: config.slack.botToken,
|
||||
appToken: config.slack.appToken,
|
||||
allowedUsers: config.slack.allowedUsers,
|
||||
groupDebounceSec: config.slack.groupDebounceSec,
|
||||
groupPollIntervalMin: config.slack.groupPollIntervalMin,
|
||||
instantGroups: config.slack.instantGroups,
|
||||
listeningGroups: config.slack.listeningGroups,
|
||||
} : { enabled: false },
|
||||
|
||||
discord: config.discord.enabled ? {
|
||||
enabled: true,
|
||||
token: config.discord.botToken,
|
||||
dmPolicy: config.discord.dmPolicy,
|
||||
allowedUsers: config.discord.allowedUsers,
|
||||
groupDebounceSec: config.discord.groupDebounceSec,
|
||||
groupPollIntervalMin: config.discord.groupPollIntervalMin,
|
||||
instantGroups: config.discord.instantGroups,
|
||||
listeningGroups: config.discord.listeningGroups,
|
||||
} : { enabled: false },
|
||||
|
||||
whatsapp: config.whatsapp.enabled ? {
|
||||
enabled: true,
|
||||
selfChat: config.whatsapp.selfChat,
|
||||
dmPolicy: config.whatsapp.dmPolicy,
|
||||
allowedUsers: config.whatsapp.allowedUsers,
|
||||
groupDebounceSec: config.whatsapp.groupDebounceSec,
|
||||
groupPollIntervalMin: config.whatsapp.groupPollIntervalMin,
|
||||
instantGroups: config.whatsapp.instantGroups,
|
||||
listeningGroups: config.whatsapp.listeningGroups,
|
||||
} : { enabled: false },
|
||||
|
||||
signal: config.signal.enabled ? {
|
||||
enabled: true,
|
||||
phone: config.signal.phoneNumber,
|
||||
selfChat: config.signal.selfChat,
|
||||
dmPolicy: config.signal.dmPolicy,
|
||||
allowedUsers: config.signal.allowedUsers,
|
||||
groupDebounceSec: config.signal.groupDebounceSec,
|
||||
groupPollIntervalMin: config.signal.groupPollIntervalMin,
|
||||
instantGroups: config.signal.instantGroups,
|
||||
listeningGroups: config.signal.listeningGroups,
|
||||
} : { enabled: false },
|
||||
},
|
||||
features: {
|
||||
cron: false,
|
||||
heartbeat: {
|
||||
enabled: false,
|
||||
intervalMin: 60,
|
||||
...(config.agentId ? { id: config.agentId } : {}),
|
||||
channels: {
|
||||
...(config.telegram.enabled ? {
|
||||
telegram: {
|
||||
enabled: true,
|
||||
token: config.telegram.botToken,
|
||||
dmPolicy: config.telegram.dmPolicy,
|
||||
allowedUsers: config.telegram.allowedUsers,
|
||||
groupDebounceSec: config.telegram.groupDebounceSec,
|
||||
groupPollIntervalMin: config.telegram.groupPollIntervalMin,
|
||||
instantGroups: config.telegram.instantGroups,
|
||||
listeningGroups: config.telegram.listeningGroups,
|
||||
}
|
||||
} : {}),
|
||||
...(config.slack.enabled ? {
|
||||
slack: {
|
||||
enabled: true,
|
||||
botToken: config.slack.botToken,
|
||||
appToken: config.slack.appToken,
|
||||
allowedUsers: config.slack.allowedUsers,
|
||||
groupDebounceSec: config.slack.groupDebounceSec,
|
||||
groupPollIntervalMin: config.slack.groupPollIntervalMin,
|
||||
instantGroups: config.slack.instantGroups,
|
||||
listeningGroups: config.slack.listeningGroups,
|
||||
}
|
||||
} : {}),
|
||||
...(config.discord.enabled ? {
|
||||
discord: {
|
||||
enabled: true,
|
||||
token: config.discord.botToken,
|
||||
dmPolicy: config.discord.dmPolicy,
|
||||
allowedUsers: config.discord.allowedUsers,
|
||||
groupDebounceSec: config.discord.groupDebounceSec,
|
||||
groupPollIntervalMin: config.discord.groupPollIntervalMin,
|
||||
instantGroups: config.discord.instantGroups,
|
||||
listeningGroups: config.discord.listeningGroups,
|
||||
}
|
||||
} : {}),
|
||||
...(config.whatsapp.enabled ? {
|
||||
whatsapp: {
|
||||
enabled: true,
|
||||
selfChat: config.whatsapp.selfChat,
|
||||
dmPolicy: config.whatsapp.dmPolicy,
|
||||
allowedUsers: config.whatsapp.allowedUsers,
|
||||
groupDebounceSec: config.whatsapp.groupDebounceSec,
|
||||
groupPollIntervalMin: config.whatsapp.groupPollIntervalMin,
|
||||
instantGroups: config.whatsapp.instantGroups,
|
||||
listeningGroups: config.whatsapp.listeningGroups,
|
||||
}
|
||||
} : {}),
|
||||
...(config.signal.enabled ? {
|
||||
signal: {
|
||||
enabled: true,
|
||||
phone: config.signal.phoneNumber,
|
||||
selfChat: config.signal.selfChat,
|
||||
dmPolicy: config.signal.dmPolicy,
|
||||
allowedUsers: config.signal.allowedUsers,
|
||||
groupDebounceSec: config.signal.groupDebounceSec,
|
||||
groupPollIntervalMin: config.signal.groupPollIntervalMin,
|
||||
instantGroups: config.signal.instantGroups,
|
||||
listeningGroups: config.signal.listeningGroups,
|
||||
}
|
||||
} : {}),
|
||||
},
|
||||
},
|
||||
features: {
|
||||
cron: false,
|
||||
heartbeat: {
|
||||
enabled: false,
|
||||
intervalMin: 60,
|
||||
},
|
||||
},
|
||||
}],
|
||||
};
|
||||
|
||||
saveConfig(lettabotConfig);
|
||||
@@ -1495,6 +1500,44 @@ export async function onboard(options?: { nonInteractive?: boolean }): Promise<v
|
||||
// Review loop
|
||||
await reviewLoop(config, env);
|
||||
|
||||
// Create agent eagerly if user chose "new" and we don't have an ID yet
|
||||
if (config.agentChoice === 'new' && !config.agentId) {
|
||||
const { createAgent } = await import('@letta-ai/letta-code-sdk');
|
||||
const { updateAgentName, ensureNoToolApprovals } = await import('./tools/letta-api.js');
|
||||
const { installSkillsToAgent } = await import('./skills/loader.js');
|
||||
const { loadMemoryBlocks } = await import('./core/memory.js');
|
||||
const { SYSTEM_PROMPT } = await import('./core/system-prompt.js');
|
||||
|
||||
const spinner = p.spinner();
|
||||
spinner.start('Creating agent...');
|
||||
try {
|
||||
const agentId = await createAgent({
|
||||
systemPrompt: SYSTEM_PROMPT,
|
||||
memory: loadMemoryBlocks(config.agentName || 'LettaBot'),
|
||||
...(config.model ? { model: config.model } : {}),
|
||||
});
|
||||
|
||||
// Set name and install skills
|
||||
if (config.agentName) {
|
||||
await updateAgentName(agentId, config.agentName).catch(() => {});
|
||||
}
|
||||
installSkillsToAgent(agentId, {
|
||||
cronEnabled: config.cron,
|
||||
googleEnabled: config.google.enabled,
|
||||
});
|
||||
|
||||
// Disable tool approvals
|
||||
ensureNoToolApprovals(agentId).catch(() => {});
|
||||
|
||||
config.agentId = agentId;
|
||||
spinner.stop(`Agent created: ${agentId}`);
|
||||
} catch (err) {
|
||||
spinner.stop('Failed to create agent');
|
||||
p.log.error(`${err}`);
|
||||
p.log.info('The agent will be created on first message instead.');
|
||||
}
|
||||
}
|
||||
|
||||
// Apply config to env
|
||||
if (config.agentName) env.AGENT_NAME = config.agentName;
|
||||
|
||||
@@ -1624,18 +1667,10 @@ export async function onboard(options?: { nonInteractive?: boolean }): Promise<v
|
||||
|
||||
p.note(summary, 'Configuration Summary');
|
||||
|
||||
// Convert to YAML config
|
||||
const yamlConfig: LettaBotConfig = {
|
||||
server: {
|
||||
mode: config.authMethod === 'selfhosted' ? 'selfhosted' : 'cloud',
|
||||
...(config.authMethod === 'selfhosted' && config.baseUrl ? { baseUrl: config.baseUrl } : {}),
|
||||
...(config.apiKey ? { apiKey: config.apiKey } : {}),
|
||||
},
|
||||
agent: {
|
||||
name: config.agentName || 'LettaBot',
|
||||
// model is configured on the Letta agent server-side, not saved to config
|
||||
...(config.agentId ? { id: config.agentId } : {}),
|
||||
},
|
||||
// Build per-agent config (multi-agent format)
|
||||
const agentConfig: AgentConfig = {
|
||||
name: config.agentName || 'LettaBot',
|
||||
...(config.agentId ? { id: config.agentId } : {}),
|
||||
channels: {
|
||||
...(config.telegram.enabled ? {
|
||||
telegram: {
|
||||
@@ -1706,13 +1741,6 @@ export async function onboard(options?: { nonInteractive?: boolean }): Promise<v
|
||||
intervalMin: config.heartbeat.interval ? parseInt(config.heartbeat.interval) : undefined,
|
||||
},
|
||||
},
|
||||
...(config.transcription.enabled && config.transcription.apiKey ? {
|
||||
transcription: {
|
||||
provider: 'openai' as const,
|
||||
apiKey: config.transcription.apiKey,
|
||||
...(config.transcription.model ? { model: config.transcription.model } : {}),
|
||||
},
|
||||
} : {}),
|
||||
...(config.google.enabled ? {
|
||||
integrations: {
|
||||
google: {
|
||||
@@ -1730,16 +1758,31 @@ export async function onboard(options?: { nonInteractive?: boolean }): Promise<v
|
||||
})()),
|
||||
} : {}),
|
||||
};
|
||||
|
||||
// Add BYOK providers if configured
|
||||
if (config.providers && config.providers.length > 0) {
|
||||
yamlConfig.providers = config.providers.map(p => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
type: p.id, // id is the type (anthropic, openai, etc.)
|
||||
apiKey: p.apiKey,
|
||||
}));
|
||||
}
|
||||
|
||||
// Convert to YAML config (multi-agent format)
|
||||
const yamlConfig: Partial<LettaBotConfig> & Pick<LettaBotConfig, 'server'> = {
|
||||
server: {
|
||||
mode: config.authMethod === 'selfhosted' ? 'selfhosted' : 'cloud',
|
||||
...(config.authMethod === 'selfhosted' && config.baseUrl ? { baseUrl: config.baseUrl } : {}),
|
||||
...(config.apiKey ? { apiKey: config.apiKey } : {}),
|
||||
},
|
||||
agents: [agentConfig],
|
||||
...(config.transcription.enabled && config.transcription.apiKey ? {
|
||||
transcription: {
|
||||
provider: 'openai' as const,
|
||||
apiKey: config.transcription.apiKey,
|
||||
...(config.transcription.model ? { model: config.transcription.model } : {}),
|
||||
},
|
||||
} : {}),
|
||||
...(config.providers && config.providers.length > 0 ? {
|
||||
providers: config.providers.map(p => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
type: p.id,
|
||||
apiKey: p.apiKey,
|
||||
})),
|
||||
} : {}),
|
||||
};
|
||||
|
||||
// Save YAML config (use project-local path)
|
||||
const savePath = resolve(process.cwd(), 'lettabot.yaml');
|
||||
|
||||
Reference in New Issue
Block a user