mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
Mothership block
This commit is contained in:
@@ -18,7 +18,7 @@ const UpdateCostSchema = z.object({
|
||||
model: z.string().min(1, 'Model is required'),
|
||||
inputTokens: z.number().min(0).default(0),
|
||||
outputTokens: z.number().min(0).default(0),
|
||||
source: z.enum(['copilot', 'workspace-chat', 'mcp_copilot']).default('copilot'),
|
||||
source: z.enum(['copilot', 'workspace-chat', 'mcp_copilot', 'mothership_block']).default('copilot'),
|
||||
})
|
||||
|
||||
/**
|
||||
|
||||
106
apps/sim/app/api/mothership/execute/route.ts
Normal file
106
apps/sim/app/api/mothership/execute/route.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { buildIntegrationToolSchemas } from '@/lib/copilot/chat-payload'
|
||||
import { orchestrateCopilotStream } from '@/lib/copilot/orchestrator'
|
||||
import { generateWorkspaceContext } from '@/lib/copilot/workspace-context'
|
||||
|
||||
const logger = createLogger('MothershipExecuteAPI')
|
||||
|
||||
const MessageSchema = z.object({
|
||||
role: z.enum(['system', 'user', 'assistant']),
|
||||
content: z.string(),
|
||||
})
|
||||
|
||||
const ExecuteRequestSchema = z.object({
|
||||
messages: z.array(MessageSchema).min(1, 'At least one message is required'),
|
||||
responseFormat: z.any().optional(),
|
||||
workspaceId: z.string().min(1, 'workspaceId is required'),
|
||||
userId: z.string().min(1, 'userId is required'),
|
||||
chatId: z.string().optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* POST /api/mothership/execute
|
||||
*
|
||||
* Non-streaming endpoint for Mothership block execution within workflows.
|
||||
* Called by the executor via internal JWT auth, not by the browser directly.
|
||||
* Consumes the Go SSE stream internally and returns a single JSON response.
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const auth = await checkInternalAuth(req, { requireWorkflowId: false })
|
||||
if (!auth.success) {
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await req.json()
|
||||
const { messages, responseFormat, workspaceId, userId, chatId } =
|
||||
ExecuteRequestSchema.parse(body)
|
||||
|
||||
const effectiveChatId = chatId || crypto.randomUUID()
|
||||
const [workspaceContext, integrationTools] = await Promise.all([
|
||||
generateWorkspaceContext(workspaceId, userId),
|
||||
buildIntegrationToolSchemas(),
|
||||
])
|
||||
|
||||
const requestPayload: Record<string, unknown> = {
|
||||
messages,
|
||||
responseFormat,
|
||||
userId,
|
||||
chatId: effectiveChatId,
|
||||
mode: 'agent',
|
||||
messageId: crypto.randomUUID(),
|
||||
isHosted: true,
|
||||
workspaceContext,
|
||||
...(integrationTools.length > 0 ? { integrationTools } : {}),
|
||||
}
|
||||
|
||||
const result = await orchestrateCopilotStream(requestPayload, {
|
||||
userId,
|
||||
workspaceId,
|
||||
chatId: effectiveChatId,
|
||||
goRoute: '/api/mothership/execute',
|
||||
autoExecuteTools: true,
|
||||
interactive: false,
|
||||
})
|
||||
|
||||
if (!result.success) {
|
||||
logger.error('Mothership execute failed', {
|
||||
error: result.error,
|
||||
errors: result.errors,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: result.error || 'Mothership execution failed',
|
||||
content: result.content || '',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
content: result.content,
|
||||
model: 'mothership',
|
||||
tokens: {},
|
||||
toolCalls: result.toolCalls,
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid request data', details: error.errors },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
logger.error('Mothership execute error', {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
})
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Internal server error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
99
apps/sim/blocks/blocks/mothership.ts
Normal file
99
apps/sim/blocks/blocks/mothership.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { Rocket } from 'lucide-react'
|
||||
import type { BlockConfig } from '@/blocks/types'
|
||||
import type { ToolResponse } from '@/tools/types'
|
||||
|
||||
interface MothershipResponse extends ToolResponse {
|
||||
output: {
|
||||
content: string
|
||||
model: string
|
||||
tokens?: {
|
||||
prompt?: number
|
||||
completion?: number
|
||||
total?: number
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const MothershipBlock: BlockConfig<MothershipResponse> = {
|
||||
type: 'mothership',
|
||||
name: 'Mothership',
|
||||
description: 'Query the Mothership AI agent',
|
||||
longDescription:
|
||||
'The Mothership block sends messages to the Mothership AI agent, which has access to subagents, integration tools, memory, and workspace context. Use it to perform complex multi-step reasoning, cross-service queries, or any task that benefits from the full Mothership intelligence within a workflow.',
|
||||
bestPractices: `
|
||||
- Use for tasks that require multi-step reasoning, tool use, or cross-service coordination.
|
||||
- Response Format should be a valid JSON Schema. When present, structured fields are returned at root level (e.g. <mothership1.field>). Without it, the block returns content, model, and tokens.
|
||||
- The Mothership picks its own model and tools internally — you only provide messages and an optional response format.
|
||||
`,
|
||||
category: 'blocks',
|
||||
bgColor: '#802FDE',
|
||||
icon: Rocket,
|
||||
subBlocks: [
|
||||
{
|
||||
id: 'messages',
|
||||
title: 'Messages',
|
||||
type: 'messages-input',
|
||||
placeholder: 'Enter messages...',
|
||||
},
|
||||
{
|
||||
id: 'responseFormat',
|
||||
title: 'Response Format',
|
||||
type: 'code',
|
||||
placeholder: 'Enter JSON schema...',
|
||||
language: 'json',
|
||||
mode: 'advanced',
|
||||
},
|
||||
{
|
||||
id: 'memoryType',
|
||||
title: 'Memory',
|
||||
type: 'dropdown',
|
||||
placeholder: 'Select memory...',
|
||||
options: [
|
||||
{ label: 'None', id: 'none' },
|
||||
{ label: 'Conversation', id: 'conversation' },
|
||||
],
|
||||
mode: 'advanced',
|
||||
},
|
||||
{
|
||||
id: 'conversationId',
|
||||
title: 'Conversation ID',
|
||||
type: 'short-input',
|
||||
placeholder: 'e.g., user-123, session-abc',
|
||||
required: {
|
||||
field: 'memoryType',
|
||||
value: ['conversation'],
|
||||
},
|
||||
condition: {
|
||||
field: 'memoryType',
|
||||
value: ['conversation'],
|
||||
},
|
||||
},
|
||||
],
|
||||
tools: {
|
||||
access: [],
|
||||
},
|
||||
inputs: {
|
||||
messages: {
|
||||
type: 'json',
|
||||
description:
|
||||
'Array of message objects with role and content: [{ role: "system", content: "..." }, { role: "user", content: "..." }]',
|
||||
},
|
||||
responseFormat: {
|
||||
type: 'json',
|
||||
description: 'JSON response format schema for structured output',
|
||||
},
|
||||
memoryType: {
|
||||
type: 'string',
|
||||
description: 'Type of memory: none (default) or conversation',
|
||||
},
|
||||
conversationId: {
|
||||
type: 'string',
|
||||
description: 'Persistent conversation ID for memory across executions',
|
||||
},
|
||||
},
|
||||
outputs: {
|
||||
content: { type: 'string', description: 'Generated response content' },
|
||||
model: { type: 'string', description: 'Model used for generation' },
|
||||
tokens: { type: 'json', description: 'Token usage statistics' },
|
||||
},
|
||||
}
|
||||
@@ -82,6 +82,7 @@ import { MailchimpBlock } from '@/blocks/blocks/mailchimp'
|
||||
import { MailgunBlock } from '@/blocks/blocks/mailgun'
|
||||
import { ManualTriggerBlock } from '@/blocks/blocks/manual_trigger'
|
||||
import { McpBlock } from '@/blocks/blocks/mcp'
|
||||
import { MothershipBlock } from '@/blocks/blocks/mothership'
|
||||
import { Mem0Block } from '@/blocks/blocks/mem0'
|
||||
import { MemoryBlock } from '@/blocks/blocks/memory'
|
||||
import { MicrosoftDataverseBlock } from '@/blocks/blocks/microsoft_dataverse'
|
||||
@@ -285,6 +286,7 @@ export const registry: Record<string, BlockConfig> = {
|
||||
mistral_parse_v2: MistralParseV2Block,
|
||||
mistral_parse_v3: MistralParseV3Block,
|
||||
mongodb: MongoDBBlock,
|
||||
mothership: MothershipBlock,
|
||||
mysql: MySQLBlock,
|
||||
neo4j: Neo4jBlock,
|
||||
note: NoteBlock,
|
||||
|
||||
@@ -25,6 +25,7 @@ export enum BlockType {
|
||||
|
||||
FUNCTION = 'function',
|
||||
AGENT = 'agent',
|
||||
MOTHERSHIP = 'mothership',
|
||||
API = 'api',
|
||||
EVALUATOR = 'evaluator',
|
||||
VARIABLES = 'variables',
|
||||
|
||||
152
apps/sim/executor/handlers/mothership/mothership-handler.ts
Normal file
152
apps/sim/executor/handlers/mothership/mothership-handler.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import type { BlockOutput } from '@/blocks/types'
|
||||
import { BlockType } from '@/executor/constants'
|
||||
import type { BlockHandler, ExecutionContext } from '@/executor/types'
|
||||
import { buildAPIUrl, buildAuthHeaders, extractAPIErrorMessage } from '@/executor/utils/http'
|
||||
import type { SerializedBlock } from '@/serializer/types'
|
||||
|
||||
const logger = createLogger('MothershipBlockHandler')
|
||||
|
||||
/**
|
||||
* Handler for Mothership blocks that proxy requests to the Mothership AI agent.
|
||||
*
|
||||
* Unlike the Agent block (which calls LLM providers directly), the Mothership
|
||||
* block delegates to the full Mothership infrastructure: main agent, subagents,
|
||||
* integration tools, memory, and workspace context.
|
||||
*/
|
||||
export class MothershipBlockHandler implements BlockHandler {
|
||||
canHandle(block: SerializedBlock): boolean {
|
||||
return block.metadata?.id === BlockType.MOTHERSHIP
|
||||
}
|
||||
|
||||
async execute(
|
||||
ctx: ExecutionContext,
|
||||
block: SerializedBlock,
|
||||
inputs: Record<string, any>
|
||||
): Promise<BlockOutput> {
|
||||
const messages = this.resolveMessages(inputs)
|
||||
const responseFormat = this.parseResponseFormat(inputs.responseFormat)
|
||||
|
||||
const memoryType = inputs.memoryType || 'none'
|
||||
const chatId =
|
||||
memoryType === 'conversation' && inputs.conversationId
|
||||
? inputs.conversationId
|
||||
: crypto.randomUUID()
|
||||
|
||||
const url = buildAPIUrl('/api/mothership/execute')
|
||||
const headers = await buildAuthHeaders()
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
messages,
|
||||
workspaceId: ctx.workspaceId || '',
|
||||
userId: ctx.userId || '',
|
||||
chatId,
|
||||
}
|
||||
if (responseFormat) {
|
||||
body.responseFormat = responseFormat
|
||||
}
|
||||
|
||||
logger.info('Executing Mothership block', {
|
||||
blockId: block.id,
|
||||
messageCount: messages.length,
|
||||
hasResponseFormat: !!responseFormat,
|
||||
memoryType,
|
||||
hasConversationId: memoryType === 'conversation',
|
||||
})
|
||||
|
||||
const response = await fetch(url.toString(), {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorMsg = await extractAPIErrorMessage(response)
|
||||
throw new Error(`Mothership execution failed: ${errorMsg}`)
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (responseFormat && result.content) {
|
||||
return this.processStructuredResponse(result)
|
||||
}
|
||||
|
||||
return {
|
||||
content: result.content || '',
|
||||
model: result.model || 'mothership',
|
||||
tokens: result.tokens || {},
|
||||
}
|
||||
}
|
||||
|
||||
private resolveMessages(
|
||||
inputs: Record<string, any>
|
||||
): Array<{ role: string; content: string }> {
|
||||
const raw = inputs.messages
|
||||
if (!raw) {
|
||||
throw new Error('Messages input is required for the Mothership block')
|
||||
}
|
||||
|
||||
let messages: unknown[]
|
||||
if (typeof raw === 'string') {
|
||||
try {
|
||||
messages = JSON.parse(raw)
|
||||
} catch {
|
||||
throw new Error('Messages must be a valid JSON array')
|
||||
}
|
||||
} else if (Array.isArray(raw)) {
|
||||
messages = raw
|
||||
} else {
|
||||
throw new Error('Messages must be an array of {role, content} objects')
|
||||
}
|
||||
|
||||
return messages.map((msg: any, i: number) => {
|
||||
if (!msg.role || typeof msg.content !== 'string') {
|
||||
throw new Error(
|
||||
`Message at index ${i} must have "role" (string) and "content" (string)`
|
||||
)
|
||||
}
|
||||
return { role: String(msg.role), content: msg.content }
|
||||
})
|
||||
}
|
||||
|
||||
private parseResponseFormat(responseFormat?: string | object): any {
|
||||
if (!responseFormat || responseFormat === '') return undefined
|
||||
|
||||
if (typeof responseFormat === 'object') return responseFormat
|
||||
|
||||
if (typeof responseFormat === 'string') {
|
||||
const trimmed = responseFormat.trim()
|
||||
if (!trimmed) return undefined
|
||||
if (trimmed.startsWith('<') || trimmed.startsWith('{{')) return undefined
|
||||
try {
|
||||
return JSON.parse(trimmed)
|
||||
} catch {
|
||||
logger.warn('Failed to parse responseFormat as JSON', {
|
||||
preview: trimmed.slice(0, 100),
|
||||
})
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
private processStructuredResponse(result: any): BlockOutput {
|
||||
const content = result.content
|
||||
try {
|
||||
const parsed = JSON.parse(content.trim())
|
||||
return {
|
||||
...parsed,
|
||||
model: result.model || 'mothership',
|
||||
tokens: result.tokens || {},
|
||||
}
|
||||
} catch {
|
||||
logger.warn('Failed to parse structured response, returning raw content')
|
||||
return {
|
||||
content,
|
||||
model: result.model || 'mothership',
|
||||
tokens: result.tokens || {},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import { ConditionBlockHandler } from '@/executor/handlers/condition/condition-h
|
||||
import { EvaluatorBlockHandler } from '@/executor/handlers/evaluator/evaluator-handler'
|
||||
import { FunctionBlockHandler } from '@/executor/handlers/function/function-handler'
|
||||
import { GenericBlockHandler } from '@/executor/handlers/generic/generic-handler'
|
||||
import { MothershipBlockHandler } from '@/executor/handlers/mothership/mothership-handler'
|
||||
import { HumanInTheLoopBlockHandler } from '@/executor/handlers/human-in-the-loop/human-in-the-loop-handler'
|
||||
import { ResponseBlockHandler } from '@/executor/handlers/response/response-handler'
|
||||
import { RouterBlockHandler } from '@/executor/handlers/router/router-handler'
|
||||
@@ -36,6 +37,7 @@ export function createBlockHandlers(): BlockHandler[] {
|
||||
new ResponseBlockHandler(),
|
||||
new HumanInTheLoopBlockHandler(),
|
||||
new AgentBlockHandler(),
|
||||
new MothershipBlockHandler(),
|
||||
new VariablesBlockHandler(),
|
||||
new WorkflowBlockHandler(),
|
||||
new WaitBlockHandler(),
|
||||
|
||||
@@ -14,7 +14,13 @@ export type UsageLogCategory = 'model' | 'fixed'
|
||||
/**
|
||||
* Usage log source types
|
||||
*/
|
||||
export type UsageLogSource = 'workflow' | 'wand' | 'copilot' | 'workspace-chat' | 'mcp_copilot'
|
||||
export type UsageLogSource =
|
||||
| 'workflow'
|
||||
| 'wand'
|
||||
| 'copilot'
|
||||
| 'workspace-chat'
|
||||
| 'mcp_copilot'
|
||||
| 'mothership_block'
|
||||
|
||||
/**
|
||||
* Metadata for 'model' category charges
|
||||
|
||||
@@ -27,7 +27,7 @@ export interface BuildPayloadParams {
|
||||
workspaceContext?: string
|
||||
}
|
||||
|
||||
interface ToolSchema {
|
||||
export interface ToolSchema {
|
||||
name: string
|
||||
description: string
|
||||
input_schema: Record<string, unknown>
|
||||
@@ -36,6 +36,48 @@ interface ToolSchema {
|
||||
oauth?: { required: boolean; provider: string }
|
||||
}
|
||||
|
||||
/**
|
||||
* Build deferred integration tool schemas from the Sim tool registry.
|
||||
* Shared by the interactive chat payload builder and the non-interactive
|
||||
* block execution route so both paths send the same tool definitions to Go.
|
||||
*/
|
||||
export async function buildIntegrationToolSchemas(): Promise<ToolSchema[]> {
|
||||
const integrationTools: ToolSchema[] = []
|
||||
try {
|
||||
const { createUserToolSchema } = await import('@/tools/params')
|
||||
const latestTools = getLatestVersionTools(tools)
|
||||
|
||||
for (const [toolId, toolConfig] of Object.entries(latestTools)) {
|
||||
try {
|
||||
const userSchema = createUserToolSchema(toolConfig)
|
||||
const strippedName = stripVersionSuffix(toolId)
|
||||
integrationTools.push({
|
||||
name: strippedName,
|
||||
description: toolConfig.description || toolConfig.name || strippedName,
|
||||
input_schema: userSchema as unknown as Record<string, unknown>,
|
||||
defer_loading: true,
|
||||
...(toolConfig.oauth?.required && {
|
||||
oauth: {
|
||||
required: true,
|
||||
provider: toolConfig.oauth.provider,
|
||||
},
|
||||
}),
|
||||
})
|
||||
} catch (toolError) {
|
||||
logger.warn('Failed to build schema for tool, skipping', {
|
||||
toolId,
|
||||
error: toolError instanceof Error ? toolError.message : String(toolError),
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('Failed to build tool schemas', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
}
|
||||
return integrationTools
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the request payload for the copilot backend.
|
||||
*/
|
||||
@@ -68,41 +110,10 @@ export async function buildCopilotRequestPayload(
|
||||
|
||||
const processedFileContents = await processFileAttachments(fileAttachments ?? [], userId)
|
||||
|
||||
const integrationTools: ToolSchema[] = []
|
||||
let integrationTools: ToolSchema[] = []
|
||||
|
||||
if (effectiveMode === 'build') {
|
||||
try {
|
||||
const { createUserToolSchema } = await import('@/tools/params')
|
||||
const latestTools = getLatestVersionTools(tools)
|
||||
|
||||
for (const [toolId, toolConfig] of Object.entries(latestTools)) {
|
||||
try {
|
||||
const userSchema = createUserToolSchema(toolConfig)
|
||||
const strippedName = stripVersionSuffix(toolId)
|
||||
integrationTools.push({
|
||||
name: strippedName,
|
||||
description: toolConfig.description || toolConfig.name || strippedName,
|
||||
input_schema: userSchema as unknown as Record<string, unknown>,
|
||||
defer_loading: true,
|
||||
...(toolConfig.oauth?.required && {
|
||||
oauth: {
|
||||
required: true,
|
||||
provider: toolConfig.oauth.provider,
|
||||
},
|
||||
}),
|
||||
})
|
||||
} catch (toolError) {
|
||||
logger.warn('Failed to build schema for tool, skipping', {
|
||||
toolId,
|
||||
error: toolError instanceof Error ? toolError.message : String(toolError),
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('Failed to build tool schemas for payload', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
}
|
||||
integrationTools = await buildIntegrationToolSchemas()
|
||||
|
||||
// Discover MCP tools from workspace servers and include as deferred tools
|
||||
if (workflowId) {
|
||||
|
||||
@@ -27,10 +27,17 @@ function addToSet(set: Set<string>, id: string): void {
|
||||
const parseEventData = (data: unknown): EventDataObject => {
|
||||
if (!data) return undefined
|
||||
if (typeof data !== 'string') {
|
||||
return data as EventDataObject
|
||||
if (typeof data === 'object' && !Array.isArray(data)) {
|
||||
return data as EventDataObject
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
try {
|
||||
return JSON.parse(data) as EventDataObject
|
||||
const parsed = JSON.parse(data)
|
||||
if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
|
||||
return parsed as EventDataObject
|
||||
}
|
||||
return undefined
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
|
||||
2
packages/db/migrations/0162_early_bloodscream.sql
Normal file
2
packages/db/migrations/0162_early_bloodscream.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
ALTER TYPE "public"."usage_log_source" ADD VALUE 'mothership_block';--> statement-breakpoint
|
||||
ALTER TABLE "mcp_servers" DROP COLUMN "copilot_enabled";
|
||||
13091
packages/db/migrations/meta/0162_snapshot.json
Normal file
13091
packages/db/migrations/meta/0162_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1128,6 +1128,13 @@
|
||||
"when": 1772217895286,
|
||||
"tag": "0161_true_songbird",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 162,
|
||||
"version": "7",
|
||||
"when": 1772482049606,
|
||||
"tag": "0162_early_bloodscream",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -2063,6 +2063,7 @@ export const usageLogSourceEnum = pgEnum('usage_log_source', [
|
||||
'wand',
|
||||
'copilot',
|
||||
'mcp_copilot',
|
||||
'mothership_block',
|
||||
])
|
||||
|
||||
export const usageLog = pgTable(
|
||||
|
||||
Reference in New Issue
Block a user