tambahan
This commit is contained in:
51
src/README.md
Normal file
51
src/README.md
Normal file
@@ -0,0 +1,51 @@
|
||||
# n8n-nodes-wajs
|
||||
|
||||
This is an n8n node to integrate with Wajs (WA-Resmi), allowing you to send various types of WhatsApp messages.
|
||||
|
||||
Wajs is a service that provides a WhatsApp API. You can find more information and documentation at [https://wa-resmi.com](https://wa-resmi.com).
|
||||
|
||||
## Installation
|
||||
|
||||
To use this node, you need to install it in your n8n setup.
|
||||
|
||||
1. Go to **Settings > Community Nodes**.
|
||||
2. Select **Install**.
|
||||
3. Enter `n8n-nodes-wajs` as the npm package name.
|
||||
4. Agree to the risks and click **Install**.
|
||||
|
||||
n8n will restart, and the new node will be available in the editor.
|
||||
|
||||
## Credentials
|
||||
|
||||
To use this node, you need to configure your Wajs credentials.
|
||||
|
||||
1. Go to the **Credentials** section in n8n.
|
||||
2. Click **Add credential**.
|
||||
3. Search for **Wajs** and select it.
|
||||
4. Fill in the following fields:
|
||||
* **API Key**: Your Wajs API Key.
|
||||
* **Device Key**: Your Wajs Device Key.
|
||||
5. Click **Save**.
|
||||
|
||||
## Supported Operations
|
||||
|
||||
This node supports the following operations for the `Message` resource:
|
||||
|
||||
* **Send Text**: Send a plain text message.
|
||||
* **Send Media**: Send media files like images, videos, audio, or documents.
|
||||
* **Send Button**: Send a message with interactive buttons.
|
||||
* **Send Template**: Send a pre-defined template message.
|
||||
* **Send Location**: Send a map location.
|
||||
* **Send Contact**: Send a contact card.
|
||||
* **Send Reaction**: Send a reaction to a message.
|
||||
* **Forward Message**: Forward an existing message.
|
||||
* **Check Number**: Check if a phone number is valid and has a WhatsApp account.
|
||||
|
||||
## Author
|
||||
|
||||
* **Name**: makuro
|
||||
* **Phone**: 6289697338821
|
||||
|
||||
## License
|
||||
|
||||
ISC
|
||||
28
src/credentials/McpClientTool.credentials.ts
Normal file
28
src/credentials/McpClientTool.credentials.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { ICredentialType, INodeProperties } from "n8n-workflow";
|
||||
|
||||
export class McpClientTool implements ICredentialType {
|
||||
name = "mcptool";
|
||||
displayName = "wajs (Bearer Token)";
|
||||
|
||||
properties: INodeProperties[] = [
|
||||
{
|
||||
displayName: "Base URL",
|
||||
name: "baseUrl",
|
||||
type: "string",
|
||||
default: "",
|
||||
placeholder: "https://api.example.com",
|
||||
description: "Masukkan URL dasar API tanpa garis miring di akhir",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
displayName: "Bearer Token",
|
||||
name: "token",
|
||||
type: "string",
|
||||
default: "",
|
||||
typeOptions: { password: true },
|
||||
description:
|
||||
"Masukkan token autentikasi Bearer (tanpa 'Bearer ' di depannya)",
|
||||
required: true,
|
||||
},
|
||||
];
|
||||
}
|
||||
512
src/nodes/McpClientTool.node.ts
Normal file
512
src/nodes/McpClientTool.node.ts
Normal file
@@ -0,0 +1,512 @@
|
||||
import {
|
||||
type IDataObject,
|
||||
type INodeExecutionData,
|
||||
type INodeType,
|
||||
type INodeTypeDescription,
|
||||
type INodePropertyOptions,
|
||||
type ILoadOptionsFunctions,
|
||||
NodeOperationError,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { OptionsWithUri } from 'request';
|
||||
|
||||
/* -------------------------
|
||||
Types
|
||||
------------------------- */
|
||||
interface MCPPayload {
|
||||
name: string;
|
||||
description?: string;
|
||||
inputSchema?: IDataObject;
|
||||
'x-props': {
|
||||
method: string;
|
||||
path: string;
|
||||
operationId?: string;
|
||||
tag?: string;
|
||||
deprecated?: boolean;
|
||||
summary?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface MCPToolsResponse {
|
||||
tools: MCPPayload[];
|
||||
}
|
||||
|
||||
/* -------------------------
|
||||
Helper Functions
|
||||
------------------------- */
|
||||
/**
|
||||
* Extract base URL and MCP path from endpoint
|
||||
*/
|
||||
function parseEndpoint(endpoint: string): { baseUrl: string; mcpPath: string } {
|
||||
const trimmed = endpoint.trim().replace(/\/+$/, '');
|
||||
|
||||
const url = new URL(trimmed);
|
||||
const pathname = url.pathname;
|
||||
|
||||
if (pathname && pathname !== '/') {
|
||||
return {
|
||||
baseUrl: `${url.protocol}//${url.host}`,
|
||||
mcpPath: pathname
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
baseUrl: trimmed,
|
||||
mcpPath: ''
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* ✅ NEW: Enhance tool description with explicit parameter names and examples
|
||||
*/
|
||||
function enhanceToolDescription(tool: MCPPayload): string {
|
||||
let desc = tool.description || tool['x-props'].summary || '';
|
||||
|
||||
// Add method and path info
|
||||
desc += `\n\n**Endpoint**: ${tool['x-props'].method} ${tool['x-props'].path}`;
|
||||
|
||||
// ✅ CRITICAL: Add explicit parameter requirements
|
||||
if (tool.inputSchema?.properties) {
|
||||
const props = tool.inputSchema.properties as IDataObject;
|
||||
const required = (tool.inputSchema.required as string[]) || [];
|
||||
|
||||
desc += '\n\n**Required Parameters (EXACT names must be used)**:';
|
||||
|
||||
const propEntries = Object.entries(props);
|
||||
|
||||
// List required fields first
|
||||
const requiredFields = propEntries.filter(([key]) => required.includes(key));
|
||||
const optionalFields = propEntries.filter(([key]) => !required.includes(key));
|
||||
|
||||
if (requiredFields.length > 0) {
|
||||
requiredFields.forEach(([key, value]: [string, any]) => {
|
||||
const type = value.type || 'string';
|
||||
const valueDesc = value.description || '';
|
||||
const example = value.example || value.examples?.[0];
|
||||
|
||||
desc += `\n • \`${key}\` (${type}, REQUIRED)`;
|
||||
if (valueDesc) desc += ` - ${valueDesc}`;
|
||||
if (example) desc += ` [Example: ${JSON.stringify(example)}]`;
|
||||
});
|
||||
}
|
||||
|
||||
if (optionalFields.length > 0) {
|
||||
desc += '\n\n**Optional Parameters**:';
|
||||
optionalFields.forEach(([key, value]: [string, any]) => {
|
||||
const type = value.type || 'string';
|
||||
const valueDesc = value.description || '';
|
||||
const example = value.example || value.examples?.[0];
|
||||
|
||||
desc += `\n • \`${key}\` (${type}, optional)`;
|
||||
if (valueDesc) desc += ` - ${valueDesc}`;
|
||||
if (example) desc += ` [Example: ${JSON.stringify(example)}]`;
|
||||
});
|
||||
}
|
||||
|
||||
// ✅ Add critical note
|
||||
desc += '\n\n⚠️ **IMPORTANT**: You MUST use the EXACT parameter names shown above. Do not abbreviate or translate them.';
|
||||
}
|
||||
|
||||
return desc;
|
||||
}
|
||||
|
||||
/**
|
||||
* ✅ NEW: Add example to schema for better AI understanding
|
||||
*/
|
||||
function enhanceInputSchema(schema: IDataObject): IDataObject {
|
||||
if (!schema?.properties) return schema;
|
||||
|
||||
const enhanced = { ...schema };
|
||||
const props = enhanced.properties as IDataObject;
|
||||
const required = (enhanced.required as string[]) || [];
|
||||
|
||||
// Build example object
|
||||
const exampleObj: IDataObject = {};
|
||||
|
||||
for (const [key, value] of Object.entries(props)) {
|
||||
const prop = value as any;
|
||||
|
||||
if (prop.example !== undefined) {
|
||||
exampleObj[key] = prop.example;
|
||||
} else if (prop.examples && Array.isArray(prop.examples) && prop.examples.length > 0) {
|
||||
exampleObj[key] = prop.examples[0];
|
||||
} else if (prop.default !== undefined) {
|
||||
exampleObj[key] = prop.default;
|
||||
} else {
|
||||
// Generate example based on type
|
||||
switch (prop.type) {
|
||||
case 'string':
|
||||
exampleObj[key] = `example_${key}`;
|
||||
break;
|
||||
case 'number':
|
||||
case 'integer':
|
||||
exampleObj[key] = 123;
|
||||
break;
|
||||
case 'boolean':
|
||||
exampleObj[key] = true;
|
||||
break;
|
||||
case 'array':
|
||||
exampleObj[key] = [];
|
||||
break;
|
||||
case 'object':
|
||||
exampleObj[key] = {};
|
||||
break;
|
||||
default:
|
||||
exampleObj[key] = `value_for_${key}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add example to schema root
|
||||
enhanced.example = exampleObj;
|
||||
|
||||
// Add title to each property emphasizing exact naming
|
||||
for (const [key, value] of Object.entries(props)) {
|
||||
const prop = value as any;
|
||||
if (!prop.title) {
|
||||
prop.title = `Parameter: ${key}`;
|
||||
}
|
||||
// Add explicit note about exact naming
|
||||
if (prop.description) {
|
||||
prop.description = `${prop.description}\n(Use exact name: "${key}")`;
|
||||
} else {
|
||||
prop.description = `Use exact parameter name: "${key}"`;
|
||||
}
|
||||
}
|
||||
|
||||
return enhanced;
|
||||
}
|
||||
|
||||
/* -------------------------
|
||||
Node Implementation
|
||||
------------------------- */
|
||||
export class McpClientTool implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
displayName: 'MCP Client Tool',
|
||||
name: 'mcpClientTool',
|
||||
icon: 'file:icon.svg',
|
||||
group: ['transform'],
|
||||
version: 1,
|
||||
usableAsTool: true,
|
||||
description:
|
||||
'Dynamically executes tools from an MCP server based on AI agent input. Fetches tool metadata from /tools and provides dynamic tool definitions for agents.',
|
||||
defaults: { name: 'MCP Client' },
|
||||
inputs: ['main'],
|
||||
outputs: ['main'],
|
||||
credentials: [{ name: 'mcptool', required: false }],
|
||||
properties: [
|
||||
{
|
||||
displayName: 'Endpoint',
|
||||
name: 'endpoint',
|
||||
type: 'string',
|
||||
default: 'http://localhost:3000/mcp',
|
||||
required: true,
|
||||
description: 'MCP server endpoint. Can include path (e.g., /mcp) for metadata endpoints.',
|
||||
},
|
||||
{
|
||||
displayName: 'Server Transport',
|
||||
name: 'transport',
|
||||
type: 'options',
|
||||
options: [
|
||||
{ name: 'HTTP (Streamable)', value: 'http' },
|
||||
{ name: 'Server Sent Events (Deprecated)', value: 'sse' },
|
||||
],
|
||||
default: 'http',
|
||||
},
|
||||
{
|
||||
displayName: 'Tools to Include',
|
||||
name: 'toolSelection',
|
||||
type: 'options',
|
||||
options: [
|
||||
{ name: 'All', value: 'all' },
|
||||
{ name: 'Selected', value: 'selected' },
|
||||
{ name: 'All Except', value: 'except' },
|
||||
],
|
||||
default: 'all',
|
||||
},
|
||||
{
|
||||
displayName: 'Selected Tools',
|
||||
name: 'selectedTools',
|
||||
type: 'multiOptions',
|
||||
typeOptions: {
|
||||
loadOptionsMethod: 'getMcpTools',
|
||||
},
|
||||
default: [],
|
||||
displayOptions: {
|
||||
show: {
|
||||
toolSelection: ['selected', 'except'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Strict Parameter Validation',
|
||||
name: 'strictValidation',
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
description: 'Whether to validate that all required parameters are provided with exact names',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
methods = {
|
||||
loadOptions: {
|
||||
async getMcpTools(this: ILoadOptionsFunctions): Promise<INodePropertyOptions[]> {
|
||||
const endpoint = this.getNodeParameter('endpoint', 0) as string;
|
||||
const { baseUrl, mcpPath } = parseEndpoint(endpoint);
|
||||
|
||||
let authHeader: any = '';
|
||||
try {
|
||||
const credentials = await this.getCredentials('mcptool');
|
||||
if (credentials?.authHeader) authHeader = credentials.authHeader;
|
||||
} catch {}
|
||||
|
||||
const toolsUrl = `${baseUrl}${mcpPath}/tools`;
|
||||
|
||||
const requestOptions: OptionsWithUri = {
|
||||
method: 'GET',
|
||||
uri: toolsUrl,
|
||||
json: true,
|
||||
headers: authHeader ? { Authorization: authHeader } : {},
|
||||
timeout: 15000,
|
||||
};
|
||||
|
||||
let toolsRes: MCPToolsResponse;
|
||||
try {
|
||||
toolsRes = await (this as any).helpers.request(requestOptions);
|
||||
} catch (err) {
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
`Failed to fetch MCP tools from ${toolsUrl}: ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!toolsRes?.tools) {
|
||||
throw new NodeOperationError(this.getNode(), 'Invalid MCP /tools response');
|
||||
}
|
||||
|
||||
return toolsRes.tools.map((tool) => ({
|
||||
name: `${tool.name} — ${tool.description || tool['x-props'].summary || ''}`,
|
||||
value: tool.name,
|
||||
}));
|
||||
},
|
||||
},
|
||||
|
||||
loadMethods: {
|
||||
async getAiToolDefinitions(this: any) {
|
||||
const endpoint = this.getNodeParameter?.('endpoint', 0) as string;
|
||||
if (!endpoint) return [];
|
||||
|
||||
const { baseUrl, mcpPath } = parseEndpoint(endpoint);
|
||||
|
||||
let authHeader = '';
|
||||
try {
|
||||
const credentials = await this.getCredentials?.('mcptool');
|
||||
if (credentials?.authHeader) authHeader = credentials.authHeader;
|
||||
} catch {}
|
||||
|
||||
const toolsUrl = `${baseUrl}${mcpPath}/tools`;
|
||||
|
||||
let toolsRes: MCPToolsResponse;
|
||||
try {
|
||||
toolsRes = await (this as any).helpers.request({
|
||||
method: 'GET',
|
||||
uri: toolsUrl,
|
||||
json: true,
|
||||
headers: authHeader ? { Authorization: authHeader } : {},
|
||||
timeout: 15000,
|
||||
});
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!Array.isArray(toolsRes?.tools)) return [];
|
||||
|
||||
const mode = this.getNodeParameter?.('toolSelection', 0) as string;
|
||||
const selected = this.getNodeParameter?.('selectedTools', 0) as string[];
|
||||
|
||||
const filtered = toolsRes.tools.filter((t) => {
|
||||
if (mode === 'all') return true;
|
||||
if (mode === 'selected') return selected.includes(t.name);
|
||||
if (mode === 'except') return !selected.includes(t.name);
|
||||
return true;
|
||||
});
|
||||
|
||||
// ✅ ENHANCED: Add detailed descriptions and examples
|
||||
return filtered.map((t) => {
|
||||
const enhancedSchema = enhanceInputSchema(
|
||||
t.inputSchema && typeof t.inputSchema === 'object'
|
||||
? t.inputSchema
|
||||
: { type: 'object', properties: {} }
|
||||
);
|
||||
|
||||
return {
|
||||
name: t.name,
|
||||
description: enhanceToolDescription(t),
|
||||
parameters: enhancedSchema,
|
||||
__meta: { ...t['x-props'] },
|
||||
};
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
async execute(this: any): Promise<INodeExecutionData[][]> {
|
||||
const items = this.getInputData();
|
||||
const output: INodeExecutionData[] = [];
|
||||
|
||||
const endpoint = this.getNodeParameter('endpoint', 0) as string;
|
||||
const strictValidation = this.getNodeParameter('strictValidation', 0, true) as boolean;
|
||||
const { baseUrl, mcpPath } = parseEndpoint(endpoint);
|
||||
|
||||
let authHeader = '';
|
||||
try {
|
||||
const credentials = await this.getCredentials('mcptool');
|
||||
if (credentials?.authHeader) authHeader = credentials.authHeader;
|
||||
} catch {}
|
||||
|
||||
const toolsUrl = `${baseUrl}${mcpPath}/tools`;
|
||||
|
||||
let toolsRes: MCPToolsResponse;
|
||||
try {
|
||||
toolsRes = await this.helpers.request({
|
||||
method: 'GET',
|
||||
uri: toolsUrl,
|
||||
json: true,
|
||||
headers: authHeader ? { Authorization: authHeader } : {},
|
||||
timeout: 15000,
|
||||
});
|
||||
} catch (err) {
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
`Failed to fetch MCP tools during execution from ${toolsUrl}: ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!toolsRes?.tools) {
|
||||
throw new NodeOperationError(this.getNode(), 'Invalid tools list from MCP server');
|
||||
}
|
||||
|
||||
const toolMap = new Map<string, MCPPayload>();
|
||||
for (const t of toolsRes.tools) toolMap.set(t.name, t);
|
||||
|
||||
for (const item of items) {
|
||||
try {
|
||||
const json = item.json || {};
|
||||
|
||||
const toolName = json.tool || json.toolName || json.tool_name || null;
|
||||
|
||||
if (!toolName) {
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
`Missing "tool" field in input JSON. Expected { "tool": "<name>", "arguments": { ... } }`,
|
||||
);
|
||||
}
|
||||
|
||||
const tool = toolMap.get(toolName);
|
||||
if (!tool) {
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
`Tool "${toolName}" not found. Available: ${[...toolMap.keys()].join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
let args: IDataObject = json.arguments || json.args || json.parameters || {};
|
||||
|
||||
// ✅ NEW: Validate required parameters if strict mode enabled
|
||||
if (strictValidation && tool.inputSchema?.required) {
|
||||
const required = tool.inputSchema.required as string[];
|
||||
const missing = required.filter(key => !(key in args));
|
||||
|
||||
if (missing.length > 0) {
|
||||
const schema = tool.inputSchema.properties as IDataObject;
|
||||
const details = missing.map(key => {
|
||||
const prop = schema[key] as any;
|
||||
const type = prop?.type || 'unknown';
|
||||
const desc = prop?.description || 'No description';
|
||||
return ` • ${key} (${type}): ${desc}`;
|
||||
}).join('\n');
|
||||
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
`Missing required parameters for tool "${toolName}":\n${details}\n\nProvided: ${JSON.stringify(Object.keys(args))}\nRequired: ${JSON.stringify(required)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ NEW: Log parameter mapping for debugging
|
||||
if (tool.inputSchema?.properties) {
|
||||
const expected = Object.keys(tool.inputSchema.properties);
|
||||
const provided = Object.keys(args);
|
||||
const unexpected = provided.filter(k => !expected.includes(k));
|
||||
|
||||
if (unexpected.length > 0) {
|
||||
console.warn(
|
||||
`Tool "${toolName}" received unexpected parameters: ${unexpected.join(', ')}. ` +
|
||||
`Expected: ${expected.join(', ')}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const method = (tool['x-props'].method || 'GET').toUpperCase();
|
||||
let path = tool['x-props'].path || '/';
|
||||
|
||||
if (!path.startsWith('/')) path = `/${path}`;
|
||||
|
||||
const url = `${baseUrl}${path}`;
|
||||
|
||||
const req: OptionsWithUri = {
|
||||
method,
|
||||
uri: url,
|
||||
json: true,
|
||||
headers: authHeader ? { Authorization: authHeader } : {},
|
||||
timeout: 20000,
|
||||
};
|
||||
|
||||
if (method === 'GET' || method === 'DELETE') {
|
||||
req.qs = args;
|
||||
} else {
|
||||
req.body = args;
|
||||
}
|
||||
|
||||
let response: any;
|
||||
try {
|
||||
response = await this.helpers.request(req);
|
||||
} catch (err: any) {
|
||||
// ✅ ENHANCED: Better error messages
|
||||
let errorMsg = `Error calling tool "${toolName}" at ${url}: ${err.message || err}`;
|
||||
|
||||
if (err.statusCode === 400) {
|
||||
errorMsg += `\n\nThis might be due to incorrect parameter names or types.`;
|
||||
errorMsg += `\nProvided parameters: ${JSON.stringify(args, null, 2)}`;
|
||||
if (tool.inputSchema?.properties) {
|
||||
errorMsg += `\nExpected parameters: ${JSON.stringify(Object.keys(tool.inputSchema.properties))}`;
|
||||
}
|
||||
}
|
||||
|
||||
throw new NodeOperationError(this.getNode(), errorMsg);
|
||||
}
|
||||
|
||||
output.push({
|
||||
json: {
|
||||
error: false,
|
||||
tool: toolName,
|
||||
method,
|
||||
path,
|
||||
url,
|
||||
response,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
output.push({
|
||||
json: {
|
||||
error: true,
|
||||
message: (error as Error).message,
|
||||
input: item.json,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return [output];
|
||||
}
|
||||
}
|
||||
17
src/nodes/icon.svg
Normal file
17
src/nodes/icon.svg
Normal file
@@ -0,0 +1,17 @@
|
||||
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128" aria-label="Icon AB square">
|
||||
<defs>
|
||||
<style>
|
||||
.letters {
|
||||
fill: #3b82f6; /* biru */
|
||||
font-family: "Inter", "Segoe UI", Roboto, sans-serif;
|
||||
font-weight: 800;
|
||||
font-size: 56px;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
|
||||
<text class="letters" x="64" y="78" text-anchor="middle" dominant-baseline="middle">WAJ</text>
|
||||
</svg>
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 434 B |
23
src/package.json
Normal file
23
src/package.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "n8n-nodes-mcp-tool",
|
||||
"version": "1.0.97",
|
||||
"keywords": [
|
||||
"n8n",
|
||||
"n8n-nodes"
|
||||
],
|
||||
"author": {
|
||||
"name": "makuro",
|
||||
"phone": "6289697338821"
|
||||
},
|
||||
"license": "ISC",
|
||||
"description": "",
|
||||
"n8n": {
|
||||
"nodes": [
|
||||
"nodes/McpClientTool.node.js"
|
||||
],
|
||||
"n8nNodesApiVersion": 1,
|
||||
"credentials": [
|
||||
"credentials/McpClientTool.credentials.js"
|
||||
]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user