Added commentary
This commit is contained in:
parent
6d21703462
commit
7fb3791c80
@ -2,6 +2,11 @@ package com.r3.developers.csdetemplate.utxoexample.workflows;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
// Class to hold the ListChatFlow results.
|
||||
// The ChatState(s) cannot be returned directly as the JsonMarshallingService can only serialize simple classes
|
||||
// that the underlying Jackson serializer recognises, hence creating a DTO style object which consists only of Strings
|
||||
// and a UUID. It is possible to create custom serializers for the JsonMarshallingService, but this beyond the scope
|
||||
// of this simple example.
|
||||
public class ChatStateResults {
|
||||
|
||||
private UUID id;
|
||||
|
@ -27,6 +27,7 @@ import java.util.UUID;
|
||||
|
||||
import static java.util.Objects.*;
|
||||
|
||||
// See Chat CorDapp Design section of the getting started docs for a description of this flow.
|
||||
public class CreateNewChatFlow implements RPCStartableFlow {
|
||||
|
||||
private final static Logger log = LoggerFactory.getLogger(CreateNewChatFlow.class);
|
||||
@ -37,12 +38,14 @@ public class CreateNewChatFlow implements RPCStartableFlow {
|
||||
@CordaInject
|
||||
public MemberLookup memberLookup;
|
||||
|
||||
// Injects the UtxoLedgerService to enable the flow to make use of the Ledger API.
|
||||
@CordaInject
|
||||
public UtxoLedgerService ledgerService;
|
||||
|
||||
@CordaInject
|
||||
public NotaryLookup notaryLookup;
|
||||
|
||||
// FlowEngine service is required to run SubFlows.
|
||||
@CordaInject
|
||||
public FlowEngine flowEngine;
|
||||
|
||||
@ -54,14 +57,17 @@ public class CreateNewChatFlow implements RPCStartableFlow {
|
||||
log.info("CreateNewChatFlow.call() called");
|
||||
|
||||
try {
|
||||
// Obtain the deserialized input arguments to the flow from the requestBody.
|
||||
CreateNewChatFlowArgs flowArgs = requestBody.getRequestBodyAs(jsonMarshallingService, CreateNewChatFlowArgs.class);
|
||||
|
||||
// Get MemberInfos for the Vnode running the flow and the otherMember.
|
||||
MemberInfo myInfo = memberLookup.myInfo();
|
||||
MemberInfo otherMember = requireNonNull(
|
||||
memberLookup.lookup(MemberX500Name.parse(flowArgs.getOtherMember())),
|
||||
"MemberLookup can't find otherMember specified in flow arguments."
|
||||
);
|
||||
|
||||
// Create the ChatState from the input arguments and member information.
|
||||
ChatState chatState = new ChatState(
|
||||
UUID.randomUUID(),
|
||||
flowArgs.getChatName(),
|
||||
@ -70,8 +76,8 @@ public class CreateNewChatFlow implements RPCStartableFlow {
|
||||
Arrays.asList(myInfo.getLedgerKeys().get(0), otherMember.getLedgerKeys().get(0))
|
||||
);
|
||||
|
||||
// Obtain the Notary name and public key.
|
||||
NotaryInfo notary = notaryLookup.getNotaryServices().iterator().next();
|
||||
|
||||
PublicKey notaryKey = null;
|
||||
for(MemberInfo memberInfo: memberLookup.lookup()){
|
||||
if(Objects.equals(
|
||||
@ -81,11 +87,13 @@ public class CreateNewChatFlow implements RPCStartableFlow {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Note, in Java CorDapps only unchecked RuntimeExceptions can be thrown not
|
||||
// declared checked exceptions as this changes the method signature and breaks override.
|
||||
if(notaryKey == null) {
|
||||
throw new CordaRuntimeException("No notary PublicKey found");
|
||||
}
|
||||
|
||||
// Use UTXOTransactionBuilder to build up the draft transaction.
|
||||
UtxoTransactionBuilder txBuilder = ledgerService.getTransactionBuilder()
|
||||
.setNotary(new Party(notary.getName(), notaryKey))
|
||||
.setTimeWindowBetween(Instant.now(), Instant.now().plusMillis(Duration.ofDays(1).toMillis()))
|
||||
@ -93,11 +101,17 @@ public class CreateNewChatFlow implements RPCStartableFlow {
|
||||
.addCommand(new ChatContract.Create())
|
||||
.addSignatories(chatState.getParticipants());
|
||||
|
||||
// Convert the transaction builder to a UTXOSignedTransaction and sign with this Vnode's first Ledger key.
|
||||
// Note, toSignedTransaction() is currently a placeholder method, hence being marked as deprecated.
|
||||
@SuppressWarnings("DEPRECATION")
|
||||
UtxoSignedTransaction signedTransaction = txBuilder.toSignedTransaction(myInfo.getLedgerKeys().get(0));
|
||||
|
||||
// Call FinalizeChatSubFlow which will finalise the transaction.
|
||||
// If successful the flow will return a String of the created transaction id,
|
||||
// if not successful it will return an error message.
|
||||
return flowEngine.subFlow(new FinalizeChatSubFlow(signedTransaction, otherMember.getName()));
|
||||
}
|
||||
// Catch any exceptions, log them and rethrow the exception.
|
||||
catch (Exception e) {
|
||||
log.warn("Failed to process utxo flow for request body " + requestBody + " because: " + e.getMessage());
|
||||
throw new CordaRuntimeException(e.getMessage());
|
||||
|
@ -1,5 +1,6 @@
|
||||
package com.r3.developers.csdetemplate.utxoexample.workflows;
|
||||
|
||||
// A class to hold the deserialized arguments required to start the flow.
|
||||
public class CreateNewChatFlowArgs{
|
||||
|
||||
// Serialisation service requires a default constructor
|
||||
|
@ -6,6 +6,7 @@ import net.corda.v5.application.flows.InitiatedBy;
|
||||
import net.corda.v5.application.flows.ResponderFlow;
|
||||
import net.corda.v5.application.messaging.FlowSession;
|
||||
import net.corda.v5.base.annotations.Suspendable;
|
||||
import net.corda.v5.base.exceptions.CordaRuntimeException;
|
||||
import net.corda.v5.base.types.MemberX500Name;
|
||||
import net.corda.v5.ledger.utxo.UtxoLedgerService;
|
||||
import net.corda.v5.ledger.utxo.transaction.UtxoSignedTransaction;
|
||||
@ -16,10 +17,15 @@ import org.slf4j.LoggerFactory;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
|
||||
// See Chat CorDapp Design section of the getting started docs for a description of this flow.
|
||||
|
||||
//@InitiatingBy declares the protocol which will be used to link the initiator to the responder.
|
||||
@InitiatedBy(protocol = "finalize-chat-protocol")
|
||||
public class FinalizeChatResponderFlow implements ResponderFlow {
|
||||
private final static Logger log = LoggerFactory.getLogger(FinalizeChatResponderFlow.class);
|
||||
|
||||
// Injects the UtxoLedgerService to enable the flow to make use of the Ledger API.
|
||||
@CordaInject
|
||||
public UtxoLedgerService utxoLedgerService;
|
||||
|
||||
@ -30,17 +36,24 @@ public class FinalizeChatResponderFlow implements ResponderFlow {
|
||||
log.info("FinalizeChatResponderFlow.call() called");
|
||||
|
||||
try {
|
||||
// Defines the lambda validator used in receiveFinality below.
|
||||
UtxoTransactionValidator txValidator = ledgerTransaction -> {
|
||||
ChatState state = (ChatState) ledgerTransaction.getOutputContractStates().get(0);
|
||||
// Uses checkForBannedWords() and checkMessageFromMatchesCounterparty() functions
|
||||
// to check whether to sign the transaction.
|
||||
if (checkForBannedWords(state.getMessage()) || !checkMessageFromMatchesCounterparty(state, session.getCounterparty())) {
|
||||
throw new IllegalStateException("Failed verification");
|
||||
throw new CordaRuntimeException("Failed verification");
|
||||
}
|
||||
log.info("Verified the transaction - " + ledgerTransaction.getId());
|
||||
};
|
||||
|
||||
// Calls receiveFinality() function which provides the responder to the finalise() function
|
||||
// in the Initiating Flow. Accepts a lambda validator containing the business logic to decide whether
|
||||
// responder should sign the Transaction.
|
||||
UtxoSignedTransaction finalizedSignedTransaction = utxoLedgerService.receiveFinality(session, txValidator);
|
||||
log.info("Finished responder flow - " + finalizedSignedTransaction.getId());
|
||||
}
|
||||
// Soft fails the flow and log the exception.
|
||||
catch(Exception e)
|
||||
{
|
||||
log.warn("Exceptionally finished responder flow", e);
|
||||
|
@ -13,6 +13,9 @@ import org.slf4j.LoggerFactory;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
// See Chat CorDapp Design section of the getting started docs for a description of this flow.
|
||||
|
||||
// @InitiatingFlow declares the protocol which will be used to link the initiator to the responder.
|
||||
@InitiatingFlow(protocol = "finalize-chat-protocol")
|
||||
public class FinalizeChatSubFlow implements SubFlow<String> {
|
||||
|
||||
@ -25,6 +28,7 @@ public class FinalizeChatSubFlow implements SubFlow<String> {
|
||||
this.otherMember = otherMember;
|
||||
}
|
||||
|
||||
// Injects the UtxoLedgerService to enable the flow to make use of the Ledger API.
|
||||
@CordaInject
|
||||
public UtxoLedgerService ledgerService;
|
||||
|
||||
@ -37,8 +41,12 @@ public class FinalizeChatSubFlow implements SubFlow<String> {
|
||||
|
||||
log.info("FinalizeChatFlow.call() called");
|
||||
|
||||
// Initiates a session with the other Member.
|
||||
FlowSession session = flowMessaging.initiateFlow(otherMember);
|
||||
|
||||
// Calls the Corda provided finalise() function which gather signatures from the counterparty,
|
||||
// notarises the transaction and persists the transaction to each party's vault.
|
||||
// On success returns the id of the transaction created. (This is different to the ChatState id)
|
||||
String result;
|
||||
try {
|
||||
List<FlowSession> sessionsList = Arrays.asList(session);
|
||||
@ -51,11 +59,13 @@ public class FinalizeChatSubFlow implements SubFlow<String> {
|
||||
result = finalizedSignedTransaction.getId().toString();
|
||||
log.info("Success! Response: " + result);
|
||||
|
||||
} catch (Exception e) {
|
||||
}
|
||||
// Soft fails the flow and returns the error message without throwing a flow exception.
|
||||
catch (Exception e) {
|
||||
log.warn("Finality failed", e);
|
||||
result = "Finality failed, " + e.getMessage();
|
||||
}
|
||||
|
||||
// Returns the transaction id converted as a string
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
@ -11,7 +11,6 @@ import net.corda.v5.crypto.SecureHash;
|
||||
import net.corda.v5.ledger.utxo.StateAndRef;
|
||||
import net.corda.v5.ledger.utxo.UtxoLedgerService;
|
||||
import net.corda.v5.ledger.utxo.transaction.UtxoLedgerTransaction;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
@ -19,6 +18,7 @@ import java.util.*;
|
||||
import static java.util.Objects.*;
|
||||
import static java.util.stream.Collectors.toList;
|
||||
|
||||
// See Chat CorDapp Design section of the getting started docs for a description of this flow.
|
||||
public class GetChatFlow implements RPCStartableFlow {
|
||||
|
||||
private final static Logger log = LoggerFactory.getLogger(GetChatFlow.class);
|
||||
@ -26,6 +26,7 @@ public class GetChatFlow implements RPCStartableFlow {
|
||||
@CordaInject
|
||||
public JsonMarshallingService jsonMarshallingService;
|
||||
|
||||
// Injects the UtxoLedgerService to enable the flow to make use of the Ledger API.
|
||||
@CordaInject
|
||||
public UtxoLedgerService ledgerService;
|
||||
|
||||
@ -34,46 +35,65 @@ public class GetChatFlow implements RPCStartableFlow {
|
||||
@Suspendable
|
||||
public String call(RPCRequestData requestBody) {
|
||||
|
||||
// Obtain the deserialized input arguments to the flow from the requestBody.
|
||||
GetChatFlowArgs flowArgs = requestBody.getRequestBodyAs(jsonMarshallingService, GetChatFlowArgs.class);
|
||||
|
||||
// Look up the latest unconsumed ChatState with the given id.
|
||||
// Note, this code brings all unconsumed states back, then filters them.
|
||||
// This is an inefficient way to perform this operation when there are a large number of chats.
|
||||
// Note, you will get this error if you input an id which has no corresponding ChatState (common error).
|
||||
List<StateAndRef<ChatState>> chatStateAndRefs = ledgerService.findUnconsumedStatesByType(ChatState.class);
|
||||
List<StateAndRef<ChatState>> chatStateAndRefsWithId = chatStateAndRefs.stream()
|
||||
.filter(sar -> sar.getState().getContractState().getId().equals(flowArgs.getId())).collect(toList());
|
||||
if (chatStateAndRefsWithId.size() != 1) throw new CordaRuntimeException("Multiple or zero Chat states with id " + flowArgs.getId() + " found");
|
||||
StateAndRef<ChatState> chatStateAndRef = chatStateAndRefsWithId.get(0);
|
||||
|
||||
// Calls resolveMessagesFromBackchain() which retrieves the chat history from the backchain.
|
||||
return jsonMarshallingService.format(resolveMessagesFromBackchain(chatStateAndRef, flowArgs.getNumberOfRecords() ));
|
||||
}
|
||||
|
||||
@NotNull
|
||||
// resoveMessageFromBackchain() starts at the stateAndRef provided, which represents the unconsumed head of the
|
||||
// backchain for this particular chat, then walks the chain backwards for the number of transaction specified in
|
||||
// the numberOfRecords argument. For each transaction it adds the MessageAndSender representing the
|
||||
// message and who sent it to a list which is then returned.
|
||||
@Suspendable
|
||||
private List<GetChatResponse> resolveMessagesFromBackchain(StateAndRef<?> stateAndRef, int numberOfRecords) {
|
||||
private List<MessageAndSender> resolveMessagesFromBackchain(StateAndRef<?> stateAndRef, int numberOfRecords) {
|
||||
|
||||
List<GetChatResponse> messages = new LinkedList<>();
|
||||
// Set up a Mutable List to collect the MessageAndSender(s)
|
||||
List<MessageAndSender> messages = new LinkedList<>();
|
||||
|
||||
// Set up initial conditions for walking the backchain.
|
||||
StateAndRef<?> currentStateAndRef = stateAndRef;
|
||||
int recordsToFetch = numberOfRecords;
|
||||
boolean moreBackchain = true;
|
||||
|
||||
// Continue to loop until the start of the backchain or enough records have been retrieved.
|
||||
while (moreBackchain) {
|
||||
|
||||
// Obtain the transaction id from the current StateAndRef and fetch the transaction from the vault.
|
||||
SecureHash transactionId = currentStateAndRef.getRef().getTransactionHash();
|
||||
|
||||
UtxoLedgerTransaction transaction = requireNonNull(
|
||||
ledgerService.findLedgerTransaction(transactionId),
|
||||
"Transaction " + transactionId + " not found."
|
||||
);
|
||||
|
||||
// Get the output state from the transaction and use it to create a MessageAndSender Object which
|
||||
// is appended to the mutable list.
|
||||
List<ChatState> chatStates = transaction.getOutputStates(ChatState.class);
|
||||
if (chatStates.size() != 1) throw new CordaRuntimeException(
|
||||
"Expecting one and only one ChatState output for transaction " + transactionId + ".");
|
||||
ChatState output = chatStates.get(0);
|
||||
|
||||
messages.add(new GetChatResponse(output.getMessageFrom().toString(), output.getMessage()));
|
||||
messages.add(new MessageAndSender(output.getMessageFrom().toString(), output.getMessage()));
|
||||
// Decrement the number of records to fetch.
|
||||
recordsToFetch--;
|
||||
|
||||
// Get the reference to the input states.
|
||||
List<StateAndRef<?>> inputStateAndRefs = transaction.getInputStateAndRefs();
|
||||
|
||||
// Check if there are no more input states (start of chain) or we have retrieved enough records.
|
||||
// Check the transaction is not malformed by having too many input states.
|
||||
// Set the currentStateAndRef to the input StateAndRef, then repeat the loop.
|
||||
if (inputStateAndRefs.isEmpty() || recordsToFetch == 0) {
|
||||
moreBackchain = false;
|
||||
} else if (inputStateAndRefs.size() > 1) {
|
||||
|
@ -2,6 +2,7 @@ package com.r3.developers.csdetemplate.utxoexample.workflows;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
// A class to hold the deserialized arguments required to start the flow.
|
||||
public class GetChatFlowArgs {
|
||||
|
||||
private UUID id;
|
||||
|
@ -14,6 +14,7 @@ import org.slf4j.LoggerFactory;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
// See Chat CorDapp Design section of the getting started docs for a description of this flow.
|
||||
public class ListChatsFlow implements RPCStartableFlow{
|
||||
|
||||
private final static Logger log = LoggerFactory.getLogger(ListChatsFlow.class);
|
||||
@ -21,6 +22,7 @@ public class ListChatsFlow implements RPCStartableFlow{
|
||||
@CordaInject
|
||||
public JsonMarshallingService jsonMarshallingService;
|
||||
|
||||
// Injects the UtxoLedgerService to enable the flow to make use of the Ledger API.
|
||||
@CordaInject
|
||||
public UtxoLedgerService utxoLedgerService;
|
||||
|
||||
@ -30,6 +32,7 @@ public class ListChatsFlow implements RPCStartableFlow{
|
||||
|
||||
log.info("ListChatsFlow.call() called");
|
||||
|
||||
// Queries the VNode's vault for unconsumed states and converts the result to a serializable DTO.
|
||||
List<StateAndRef<ChatState>> states = utxoLedgerService.findUnconsumedStatesByType(ChatState.class);
|
||||
List<ChatStateResults> results = states.stream().map( stateAndRef ->
|
||||
new ChatStateResults(
|
||||
@ -40,6 +43,7 @@ public class ListChatsFlow implements RPCStartableFlow{
|
||||
)
|
||||
).collect(Collectors.toList());
|
||||
|
||||
// Uses the JsonMarshallingService's format() function to serialize the DTO to Json.
|
||||
return jsonMarshallingService.format(results);
|
||||
}
|
||||
}
|
||||
|
@ -1,12 +1,13 @@
|
||||
package com.r3.developers.csdetemplate.utxoexample.workflows;
|
||||
|
||||
public class GetChatResponse {
|
||||
// A class to pair the messageFrom and message together.
|
||||
public class MessageAndSender {
|
||||
|
||||
private String messageFrom;
|
||||
private String message;
|
||||
public GetChatResponse() {}
|
||||
public MessageAndSender() {}
|
||||
|
||||
public GetChatResponse(String messageFrom, String message) {
|
||||
public MessageAndSender(String messageFrom, String message) {
|
||||
this.messageFrom = messageFrom;
|
||||
this.message = message;
|
||||
}
|
@ -25,6 +25,7 @@ import java.util.List;
|
||||
import static java.util.Objects.*;
|
||||
import static java.util.stream.Collectors.toList;
|
||||
|
||||
// See Chat CorDapp Design section of the getting started docs for a description of this flow.
|
||||
public class UpdateChatFlow implements RPCStartableFlow {
|
||||
|
||||
private final static Logger log = LoggerFactory.getLogger(UpdateChatFlow.class);
|
||||
@ -35,9 +36,11 @@ public class UpdateChatFlow implements RPCStartableFlow {
|
||||
@CordaInject
|
||||
public MemberLookup memberLookup;
|
||||
|
||||
// Injects the UtxoLedgerService to enable the flow to make use of the Ledger API.
|
||||
@CordaInject
|
||||
public UtxoLedgerService ledgerService;
|
||||
|
||||
// FlowEngine service is required to run SubFlows.
|
||||
@CordaInject
|
||||
public FlowEngine flowEngine;
|
||||
|
||||
@ -48,29 +51,34 @@ public class UpdateChatFlow implements RPCStartableFlow {
|
||||
log.info("UpdateNewChatFlow.call() called");
|
||||
|
||||
try {
|
||||
// Obtain the deserialized input arguments to the flow from the requestBody.
|
||||
UpdateChatFlowArgs flowArgs = requestBody.getRequestBodyAs(jsonMarshallingService, UpdateChatFlowArgs.class);
|
||||
|
||||
// Look up the latest unconsumed ChatState with the given id.
|
||||
// Note, this code brings all unconsumed states back, then filters them.
|
||||
// This is an inefficient way to perform this operation when there are a large number of chats.
|
||||
// Note, you will get this error if you input an id which has no corresponding ChatState (common error).
|
||||
List<StateAndRef<ChatState>> chatStateAndRefs = ledgerService.findUnconsumedStatesByType(ChatState.class);
|
||||
List<StateAndRef<ChatState>> chatStateAndRefsWithId = chatStateAndRefs.stream()
|
||||
.filter(sar -> sar.getState().getContractState().getId().equals(flowArgs.getId())).collect(toList());
|
||||
if (chatStateAndRefsWithId.size() != 1) throw new CordaRuntimeException("Multiple or zero Chat states with id " + flowArgs.getId() + " found");
|
||||
StateAndRef<ChatState> chatStateAndRef = chatStateAndRefsWithId.get(0);
|
||||
|
||||
|
||||
// Get MemberInfos for the Vnode running the flow and the otherMember.
|
||||
MemberInfo myInfo = memberLookup.myInfo();
|
||||
ChatState state = chatStateAndRef.getState().getContractState();
|
||||
|
||||
List<MemberInfo> members = state.getParticipants().stream().map(
|
||||
it -> requireNonNull(memberLookup.lookup(it), "Member not found from public Key "+ it + ".")
|
||||
).collect(toList());
|
||||
|
||||
members.remove(myInfo);
|
||||
if(members.size() != 1) throw new RuntimeException("Should be only one participant other than the initiator");
|
||||
|
||||
MemberInfo otherMember = members.get(0);
|
||||
|
||||
// Create a new ChatState using the updateMessage helper function.
|
||||
ChatState newChatState = state.updateMessage(myInfo.getName(), flowArgs.getMessage());
|
||||
|
||||
// Use UTXOTransactionBuilder to build up the draft transaction.
|
||||
UtxoTransactionBuilder txBuilder = ledgerService.getTransactionBuilder()
|
||||
.setNotary(chatStateAndRef.getState().getNotary())
|
||||
.setTimeWindowBetween(Instant.now(), Instant.now().plusMillis(Duration.ofDays(1).toMillis()))
|
||||
@ -79,11 +87,20 @@ public class UpdateChatFlow implements RPCStartableFlow {
|
||||
.addCommand(new ChatContract.Update())
|
||||
.addSignatories(newChatState.getParticipants());
|
||||
|
||||
// Convert the transaction builder to a UtxoSignedTransaction and sign with this Vnode's first Ledger key.
|
||||
// Note, toSignedTransaction() is currently a placeholder method, hence being marked as deprecated.
|
||||
@SuppressWarnings("DEPRECATION")
|
||||
UtxoSignedTransaction signedTransaction = txBuilder.toSignedTransaction(myInfo.getLedgerKeys().get(0));
|
||||
|
||||
// Call FinalizeChatSubFlow which will finalise the transaction.
|
||||
// If successful the flow will return a String of the created transaction id,
|
||||
// if not successful it will return an error message.
|
||||
return flowEngine.subFlow(new FinalizeChatSubFlow(signedTransaction, otherMember.getName()));
|
||||
} catch (Exception e) {
|
||||
|
||||
|
||||
}
|
||||
// Catch any exceptions, log them and rethrow the exception.
|
||||
catch (Exception e) {
|
||||
log.warn("Failed to process utxo flow for request body " + requestBody + " because: " + e.getMessage());
|
||||
throw e;
|
||||
}
|
||||
|
@ -2,6 +2,7 @@ package com.r3.developers.csdetemplate.utxoexample.workflows;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
// A class to hold the deserialized arguments required to start the flow.
|
||||
public class UpdateChatFlowArgs {
|
||||
public UpdateChatFlowArgs() {}
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user