mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
.NET: [Feature Branch] Update HTTP API to be consistent across languages (#2118)
This commit is contained in:
committed by
GitHub
Unverified
parent
586238c1c3
commit
2329bd4841
@@ -35,8 +35,55 @@ Invoke-RestMethod -Method Post `
|
||||
-Body "Tell me a joke about a pirate."
|
||||
```
|
||||
|
||||
The response from the agent will be displayed in the terminal where you ran `func start`. The expected output will look something like:
|
||||
You can also send JSON requests:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:7071/api/agents/Joker/run \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Accept: application/json" \
|
||||
-d '{"message": "Tell me a joke about a pirate."}'
|
||||
```
|
||||
|
||||
To continue a conversation, include the `thread_id` in the query string or JSON body:
|
||||
|
||||
```bash
|
||||
curl -X POST "http://localhost:7071/api/agents/Joker/run?thread_id=@dafx-joker@your-thread-id" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Accept: application/json" \
|
||||
-d '{"message": "Tell me another one."}'
|
||||
```
|
||||
|
||||
The response from the agent will be displayed in the terminal where you ran `func start`. The expected `text/plain` output will look something like:
|
||||
|
||||
```text
|
||||
Why don't pirates ever learn the alphabet? Because they always get stuck at "C"!
|
||||
```
|
||||
|
||||
The expected `application/json` output will look something like:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": 200,
|
||||
"thread_id": "@dafx-joker@your-thread-id",
|
||||
"response": {
|
||||
"Messages": [
|
||||
{
|
||||
"AuthorName": "Joker",
|
||||
"CreatedAt": "2025-11-11T12:00:00.0000000Z",
|
||||
"Role": "assistant",
|
||||
"Contents": [
|
||||
{
|
||||
"Type": "text",
|
||||
"Text": "Why don't pirates ever learn the alphabet? Because they always get stuck at 'C'!"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"Usage": {
|
||||
"InputTokenCount": 78,
|
||||
"OutputTokenCount": 36,
|
||||
"TotalTokenCount": 114
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -29,7 +29,7 @@ curl -i -X POST http://localhost:7071/api/agents/publisher/run \
|
||||
-d 'Start a content generation workflow for the topic \"The Future of Artificial Intelligence\"'
|
||||
|
||||
# Save the thread ID to a variable and print it to the terminal
|
||||
threadId=$(cat headers.txt | grep "X-Agent-Thread" | cut -d' ' -f2)
|
||||
threadId=$(cat headers.txt | grep "x-ms-thread-id" | cut -d' ' -f2)
|
||||
echo "Thread ID: $threadId"
|
||||
```
|
||||
|
||||
@@ -43,7 +43,7 @@ Invoke-RestMethod -Method Post `
|
||||
-Body 'Start a content generation workflow for the topic \"The Future of Artificial Intelligence\"' `
|
||||
|
||||
# Save the thread ID to a variable and print it to the console
|
||||
$threadId = $ResponseHeaders['X-Agent-Thread']
|
||||
$threadId = $ResponseHeaders['x-ms-thread-id']
|
||||
Write-Host "Thread ID: $threadId"
|
||||
```
|
||||
|
||||
@@ -52,12 +52,12 @@ The response will be a text string that looks something like the following, indi
|
||||
```http
|
||||
HTTP/1.1 200 OK
|
||||
Content-Type: text/plain
|
||||
X-Agent-Thread: @publisher@351ec855-7f4d-4527-a60d-498301ced36d
|
||||
x-ms-thread-id: @publisher@351ec855-7f4d-4527-a60d-498301ced36d
|
||||
|
||||
```
|
||||
|
||||
The `x-ms-thread-id` response header contains the thread ID, which can be used to continue the conversation by passing it as a query parameter (`thread_id`) to the `run` endpoint. The commands above show how to save the thread ID to a `$threadId` variable for use in subsequent requests.
|
||||
|
||||
|
||||
Behind the scenes, the publisher agent will:
|
||||
|
||||
1. Start the content generation workflow via a tool call
|
||||
@@ -70,12 +70,12 @@ Bash (Linux/macOS/WSL):
|
||||
```bash
|
||||
# Approve the content
|
||||
curl -X POST "http://localhost:7071/api/agents/publisher/run?thread_id=$threadId" \
|
||||
-H "Content-Type: text/plain" \
|
||||
-H "Content-Type: text/plain" \
|
||||
-d 'Approve the content'
|
||||
|
||||
# Reject the content with feedback
|
||||
curl -X POST "http://localhost:7071/api/agents/publisher/run?thread_id=$threadId" \
|
||||
-H "Content-Type: text/plain" \
|
||||
-H "Content-Type: text/plain" \
|
||||
-d 'Reject the content with feedback: The article needs more technical depth and better examples.'
|
||||
```
|
||||
|
||||
@@ -85,13 +85,13 @@ PowerShell:
|
||||
# Approve the content
|
||||
Invoke-RestMethod -Method Post `
|
||||
-Uri "http://localhost:7071/api/agents/publisher/run?thread_id=$threadId" `
|
||||
-ContentType text/plain `
|
||||
-ContentType text/plain `
|
||||
-Body 'Approve the content'
|
||||
|
||||
# Reject the content with feedback
|
||||
Invoke-RestMethod -Method Post `
|
||||
-Uri "http://localhost:7071/api/agents/publisher/run?thread_id=$threadId" `
|
||||
-ContentType text/plain `
|
||||
-ContentType text/plain `
|
||||
-Body 'Reject the content with feedback: The article needs more technical depth and better examples.'
|
||||
```
|
||||
|
||||
@@ -101,7 +101,7 @@ Once the workflow has completed, you can get the status by prompting the publish
|
||||
|
||||
```bash
|
||||
curl -X POST "http://localhost:7071/api/agents/publisher/run?thread_id=$threadId" \
|
||||
-H "Content-Type: text/plain" \
|
||||
-H "Content-Type: text/plain" \
|
||||
-d 'Get the status of the workflow you previously started'
|
||||
```
|
||||
|
||||
@@ -110,7 +110,7 @@ PowerShell:
|
||||
```powershell
|
||||
Invoke-RestMethod -Method Post `
|
||||
-Uri "http://localhost:7071/api/agents/publisher/run?thread_id=$threadId" `
|
||||
-ContentType text/plain `
|
||||
-ContentType text/plain `
|
||||
-Body 'Get the status of the workflow you previously started'
|
||||
```
|
||||
|
||||
|
||||
@@ -9,19 +9,19 @@ Start a content generation workflow for the topic 'The Future of Artificial Inte
|
||||
@threadId = <YOUR_THREAD_ID>
|
||||
|
||||
### Check the status of the workflow
|
||||
POST http://localhost:7071/api/agents/publisher/run?threadId={{threadId}}
|
||||
POST http://localhost:7071/api/agents/publisher/run?thread_id={{threadId}}
|
||||
Content-Type: text/plain
|
||||
|
||||
Check the status of the workflow you previously started
|
||||
|
||||
### Reject content with feedback
|
||||
POST http://localhost:7071/api/agents/publisher/run?threadId={{threadId}}
|
||||
POST http://localhost:7071/api/agents/publisher/run?thread_id={{threadId}}
|
||||
Content-Type: text/plain
|
||||
|
||||
Reject the content with feedback: The article needs more technical depth and better examples.
|
||||
|
||||
### Approve content
|
||||
POST http://localhost:7071/api/agents/publisher/run?threadId={{threadId}}
|
||||
POST http://localhost:7071/api/agents/publisher/run?thread_id={{threadId}}
|
||||
Content-Type: text/plain
|
||||
|
||||
Approve the content
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Net;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Agents.AI.DurableTask;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Extensions.Mcp;
|
||||
@@ -45,55 +46,101 @@ internal static class BuiltInFunctions
|
||||
[DurableClient] DurableTaskClient client,
|
||||
FunctionContext context)
|
||||
{
|
||||
// The session ID is an optional query string parameter.
|
||||
string? threadIdFromQuery = req.Query["threadId"];
|
||||
// Parse request body - support both JSON and plain text
|
||||
string? message = null;
|
||||
string? threadIdFromBody = null;
|
||||
|
||||
if (req.Headers.TryGetValues("Content-Type", out IEnumerable<string>? contentTypeValues) &&
|
||||
contentTypeValues.Any(ct => ct.Contains("application/json", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
// Parse JSON body using POCO record
|
||||
AgentRunRequest? requestBody = await req.ReadFromJsonAsync<AgentRunRequest>(context.CancellationToken);
|
||||
if (requestBody != null)
|
||||
{
|
||||
message = requestBody.Message;
|
||||
threadIdFromBody = requestBody.ThreadId;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Plain text body
|
||||
message = await req.ReadAsStringAsync();
|
||||
}
|
||||
|
||||
// The thread ID can come from query string or JSON body
|
||||
string? threadIdFromQuery = req.Query["thread_id"];
|
||||
|
||||
// Validate that if thread_id is specified in both places, they must match
|
||||
if (!string.IsNullOrEmpty(threadIdFromQuery) && !string.IsNullOrEmpty(threadIdFromBody) &&
|
||||
!string.Equals(threadIdFromQuery, threadIdFromBody, StringComparison.Ordinal))
|
||||
{
|
||||
return await CreateErrorResponseAsync(
|
||||
req,
|
||||
context,
|
||||
HttpStatusCode.BadRequest,
|
||||
"thread_id specified in both query string and request body must match.");
|
||||
}
|
||||
|
||||
string? threadIdValue = threadIdFromBody ?? threadIdFromQuery;
|
||||
|
||||
// If no session ID is provided, use a new one based on the function name and invocation ID.
|
||||
// This may be better than a random one because it can be correlated with the function invocation.
|
||||
// Specifying a session ID is how the caller correlates multiple calls to the same agent session.
|
||||
AgentSessionId sessionId = string.IsNullOrEmpty(threadIdFromQuery)
|
||||
AgentSessionId sessionId = string.IsNullOrEmpty(threadIdValue)
|
||||
? new AgentSessionId(GetAgentName(context), context.InvocationId)
|
||||
: AgentSessionId.Parse(threadIdFromQuery);
|
||||
: AgentSessionId.Parse(threadIdValue);
|
||||
|
||||
string? message = await req.ReadAsStringAsync();
|
||||
if (string.IsNullOrWhiteSpace(message))
|
||||
{
|
||||
HttpResponseData response = req.CreateResponse(HttpStatusCode.BadRequest);
|
||||
await response.WriteAsJsonAsync(
|
||||
new { error = "Run request cannot be empty." },
|
||||
context.CancellationToken);
|
||||
return response;
|
||||
return await CreateErrorResponseAsync(
|
||||
req,
|
||||
context,
|
||||
HttpStatusCode.BadRequest,
|
||||
"Run request cannot be empty.");
|
||||
}
|
||||
|
||||
// Check if we should wait for response (default is true)
|
||||
bool waitForResponse = true;
|
||||
if (req.Headers.TryGetValues("x-ms-wait-for-response", out IEnumerable<string>? waitForResponseValues))
|
||||
{
|
||||
string? waitForResponseValue = waitForResponseValues.FirstOrDefault();
|
||||
if (!string.IsNullOrEmpty(waitForResponseValue) && bool.TryParse(waitForResponseValue, out bool parsedValue))
|
||||
{
|
||||
waitForResponse = parsedValue;
|
||||
}
|
||||
}
|
||||
|
||||
AIAgent agentProxy = client.AsDurableAgentProxy(context, GetAgentName(context));
|
||||
|
||||
AgentRunResponse agentResponse = await agentProxy.RunAsync(
|
||||
DurableAgentRunOptions options = new() { IsFireAndForget = !waitForResponse };
|
||||
|
||||
if (waitForResponse)
|
||||
{
|
||||
AgentRunResponse agentResponse = await agentProxy.RunAsync(
|
||||
message: new ChatMessage(ChatRole.User, message),
|
||||
thread: new DurableAgentThread(sessionId),
|
||||
options: options,
|
||||
cancellationToken: context.CancellationToken);
|
||||
|
||||
return await CreateSuccessResponseAsync(
|
||||
req,
|
||||
context,
|
||||
HttpStatusCode.OK,
|
||||
sessionId.ToString(),
|
||||
agentResponse);
|
||||
}
|
||||
|
||||
// Fire and forget - return 202 Accepted
|
||||
await agentProxy.RunAsync(
|
||||
message: new ChatMessage(ChatRole.User, message),
|
||||
thread: new DurableAgentThread(sessionId),
|
||||
options: null,
|
||||
options: options,
|
||||
cancellationToken: context.CancellationToken);
|
||||
|
||||
HttpResponseData httpResponse = req.CreateResponse(HttpStatusCode.OK);
|
||||
httpResponse.Headers.Add("X-Agent-Thread", sessionId.ToString());
|
||||
|
||||
// If the caller accepts JSON, return the entire response object as JSON.
|
||||
// TODO: Need to define a standard response schema for agent responses.
|
||||
// https://github.com/Azure/durable-agent-framework/issues/113
|
||||
if (req.Headers.TryGetValues("Accept", out IEnumerable<string>? acceptValues) &&
|
||||
acceptValues.Contains("application/json", StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
await httpResponse.WriteAsJsonAsync(
|
||||
new { response = agentResponse, threadId = sessionId },
|
||||
context.CancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
// The default is to return the response as text/plain.
|
||||
httpResponse.Headers.Add("Content-Type", "text/plain");
|
||||
await httpResponse.WriteStringAsync(agentResponse.Text, context.CancellationToken);
|
||||
}
|
||||
|
||||
return httpResponse;
|
||||
return await CreateAcceptedResponseAsync(
|
||||
req,
|
||||
context,
|
||||
sessionId.ToString());
|
||||
}
|
||||
|
||||
public static async Task<string?> RunMcpToolAsync(
|
||||
@@ -128,6 +175,106 @@ internal static class BuiltInFunctions
|
||||
return agentResponse.Text;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an error response with the specified status code and error message.
|
||||
/// </summary>
|
||||
/// <param name="req">The HTTP request data.</param>
|
||||
/// <param name="context">The function context.</param>
|
||||
/// <param name="statusCode">The HTTP status code.</param>
|
||||
/// <param name="errorMessage">The error message.</param>
|
||||
/// <returns>The HTTP response data containing the error.</returns>
|
||||
private static async Task<HttpResponseData> CreateErrorResponseAsync(
|
||||
HttpRequestData req,
|
||||
FunctionContext context,
|
||||
HttpStatusCode statusCode,
|
||||
string errorMessage)
|
||||
{
|
||||
HttpResponseData response = req.CreateResponse(statusCode);
|
||||
bool acceptsJson = req.Headers.TryGetValues("Accept", out IEnumerable<string>? acceptValues) &&
|
||||
acceptValues.Contains("application/json", StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
if (acceptsJson)
|
||||
{
|
||||
ErrorResponse errorResponse = new((int)statusCode, errorMessage);
|
||||
await response.WriteAsJsonAsync(errorResponse, context.CancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
response.Headers.Add("Content-Type", "text/plain");
|
||||
await response.WriteStringAsync(errorMessage, context.CancellationToken);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a successful agent run response with the agent's response.
|
||||
/// </summary>
|
||||
/// <param name="req">The HTTP request data.</param>
|
||||
/// <param name="context">The function context.</param>
|
||||
/// <param name="statusCode">The HTTP status code (typically 200 OK).</param>
|
||||
/// <param name="threadId">The thread ID for the conversation.</param>
|
||||
/// <param name="agentResponse">The agent's response.</param>
|
||||
/// <returns>The HTTP response data containing the success response.</returns>
|
||||
private static async Task<HttpResponseData> CreateSuccessResponseAsync(
|
||||
HttpRequestData req,
|
||||
FunctionContext context,
|
||||
HttpStatusCode statusCode,
|
||||
string threadId,
|
||||
AgentRunResponse agentResponse)
|
||||
{
|
||||
HttpResponseData response = req.CreateResponse(statusCode);
|
||||
response.Headers.Add("x-ms-thread-id", threadId);
|
||||
|
||||
bool acceptsJson = req.Headers.TryGetValues("Accept", out IEnumerable<string>? acceptValues) &&
|
||||
acceptValues.Contains("application/json", StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
if (acceptsJson)
|
||||
{
|
||||
AgentRunSuccessResponse successResponse = new((int)statusCode, threadId, agentResponse);
|
||||
await response.WriteAsJsonAsync(successResponse, context.CancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
response.Headers.Add("Content-Type", "text/plain");
|
||||
await response.WriteStringAsync(agentResponse.Text, context.CancellationToken);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an accepted (fire-and-forget) agent run response.
|
||||
/// </summary>
|
||||
/// <param name="req">The HTTP request data.</param>
|
||||
/// <param name="context">The function context.</param>
|
||||
/// <param name="threadId">The thread ID for the conversation.</param>
|
||||
/// <returns>The HTTP response data containing the accepted response.</returns>
|
||||
private static async Task<HttpResponseData> CreateAcceptedResponseAsync(
|
||||
HttpRequestData req,
|
||||
FunctionContext context,
|
||||
string threadId)
|
||||
{
|
||||
HttpResponseData response = req.CreateResponse(HttpStatusCode.Accepted);
|
||||
response.Headers.Add("x-ms-thread-id", threadId);
|
||||
|
||||
bool acceptsJson = req.Headers.TryGetValues("Accept", out IEnumerable<string>? acceptValues) &&
|
||||
acceptValues.Contains("application/json", StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
if (acceptsJson)
|
||||
{
|
||||
AgentRunAcceptedResponse acceptedResponse = new((int)HttpStatusCode.Accepted, threadId);
|
||||
await response.WriteAsJsonAsync(acceptedResponse, context.CancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
response.Headers.Add("Content-Type", "text/plain");
|
||||
await response.WriteStringAsync("Request accepted.", context.CancellationToken);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
private static string GetAgentName(FunctionContext context)
|
||||
{
|
||||
// Check if the function name starts with the HttpPrefix
|
||||
@@ -144,6 +291,44 @@ internal static class BuiltInFunctions
|
||||
return functionName[HttpPrefix.Length..];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a request to run an agent.
|
||||
/// </summary>
|
||||
/// <param name="Message">The message to send to the agent.</param>
|
||||
/// <param name="ThreadId">The optional thread ID to continue a conversation.</param>
|
||||
private sealed record AgentRunRequest(
|
||||
[property: JsonPropertyName("message")] string? Message,
|
||||
[property: JsonPropertyName("thread_id")] string? ThreadId);
|
||||
|
||||
/// <summary>
|
||||
/// Represents an error response.
|
||||
/// </summary>
|
||||
/// <param name="Status">The HTTP status code.</param>
|
||||
/// <param name="Error">The error message.</param>
|
||||
private sealed record ErrorResponse(
|
||||
[property: JsonPropertyName("status")] int Status,
|
||||
[property: JsonPropertyName("error")] string Error);
|
||||
|
||||
/// <summary>
|
||||
/// Represents a successful agent run response.
|
||||
/// </summary>
|
||||
/// <param name="Status">The HTTP status code.</param>
|
||||
/// <param name="ThreadId">The thread ID for the conversation.</param>
|
||||
/// <param name="Response">The agent response.</param>
|
||||
private sealed record AgentRunSuccessResponse(
|
||||
[property: JsonPropertyName("status")] int Status,
|
||||
[property: JsonPropertyName("thread_id")] string ThreadId,
|
||||
[property: JsonPropertyName("response")] AgentRunResponse Response);
|
||||
|
||||
/// <summary>
|
||||
/// Represents an accepted (fire-and-forget) agent run response.
|
||||
/// </summary>
|
||||
/// <param name="Status">The HTTP status code.</param>
|
||||
/// <param name="ThreadId">The thread ID for the conversation.</param>
|
||||
private sealed record AgentRunAcceptedResponse(
|
||||
[property: JsonPropertyName("status")] int Status,
|
||||
[property: JsonPropertyName("thread_id")] string ThreadId);
|
||||
|
||||
/// <summary>
|
||||
/// A service provider that combines the original service provider with an additional DurableTaskClient instance.
|
||||
/// </summary>
|
||||
|
||||
+4
-4
@@ -73,7 +73,7 @@ public sealed class SamplesValidation(ITestOutputHelper outputHelper) : IAsyncLi
|
||||
this._outputHelper.WriteLine($"Agent run response: {responseText}");
|
||||
|
||||
// The response headers should include the agent thread ID, which can be used to continue the conversation.
|
||||
string? threadId = response.Headers.GetValues("X-Agent-Thread")?.FirstOrDefault();
|
||||
string? threadId = response.Headers.GetValues("x-ms-thread-id")?.FirstOrDefault();
|
||||
Assert.NotNull(threadId);
|
||||
|
||||
this._outputHelper.WriteLine($"Agent thread ID: {threadId}");
|
||||
@@ -286,7 +286,7 @@ public sealed class SamplesValidation(ITestOutputHelper outputHelper) : IAsyncLi
|
||||
this._outputHelper.WriteLine($"Agent response: {startResponseText}");
|
||||
|
||||
// The response should be deserializable as an AgentRunResponse object and have a valid thread ID
|
||||
startResponse.Headers.TryGetValues("X-Agent-Thread", out IEnumerable<string>? agentIdValues);
|
||||
startResponse.Headers.TryGetValues("x-ms-thread-id", out IEnumerable<string>? agentIdValues);
|
||||
string? threadId = agentIdValues?.FirstOrDefault();
|
||||
Assert.NotNull(threadId);
|
||||
Assert.StartsWith("@dafx-publisher@", threadId);
|
||||
@@ -307,7 +307,7 @@ public sealed class SamplesValidation(ITestOutputHelper outputHelper) : IAsyncLi
|
||||
timeout: TimeSpan.FromSeconds(60));
|
||||
|
||||
// Approve the content
|
||||
Uri approvalUri = new($"{runAgentUri}?threadId={threadId}");
|
||||
Uri approvalUri = new($"{runAgentUri}?thread_id={threadId}");
|
||||
using HttpContent approvalContent = new StringContent("Approve the content", Encoding.UTF8, "text/plain");
|
||||
using HttpResponseMessage approvalResponse = await s_sharedHttpClient.PostAsync(approvalUri, approvalContent);
|
||||
Assert.True(approvalResponse.IsSuccessStatusCode, $"Approve content request failed with status: {approvalResponse.StatusCode}");
|
||||
@@ -327,7 +327,7 @@ public sealed class SamplesValidation(ITestOutputHelper outputHelper) : IAsyncLi
|
||||
timeout: TimeSpan.FromSeconds(60));
|
||||
|
||||
// Verify the final orchestration status by asking the agent for the status
|
||||
Uri statusUri = new($"{runAgentUri}?threadId={threadId}");
|
||||
Uri statusUri = new($"{runAgentUri}?thread_id={threadId}");
|
||||
await this.WaitForConditionAsync(
|
||||
condition: async () =>
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user