diff --git a/CSharp/BotBuilderLocation/Bing/BingGeoSpatialService.cs b/CSharp/BotBuilderLocation/Bing/BingGeoSpatialService.cs index c1e2e22..acf945f 100644 --- a/CSharp/BotBuilderLocation/Bing/BingGeoSpatialService.cs +++ b/CSharp/BotBuilderLocation/Bing/BingGeoSpatialService.cs @@ -5,6 +5,7 @@ using System.Linq; using System.Net.Http; using System.Threading.Tasks; + using Internals.Fibers; using Newtonsoft.Json; [Serializable] @@ -16,39 +17,31 @@ private readonly static string ImageUrlByPoint = $"https://dev.virtualearth.net/REST/V1/Imagery/Map/Road/{{0}},{{1}}/15?form={FormCode}&mapSize=500,280&pp={{0}},{{1}};1;{{2}}&dpi=1&logo=always"; private readonly static string ImageUrlByBBox = $"https://dev.virtualearth.net/REST/V1/Imagery/Map/Road?form={FormCode}&mapArea={{0}},{{1}},{{2}},{{3}}&mapSize=500,280&pp={{4}},{{5}};1;{{6}}&dpi=1&logo=always"; - public async Task GetLocationsByQueryAsync(string apiKey, string address) - { - if (string.IsNullOrEmpty(apiKey)) - { - throw new ArgumentNullException(nameof(apiKey)); - } + private readonly string apiKey; + internal BingGeoSpatialService(string apiKey) + { + SetField.NotNull(out this.apiKey, nameof(apiKey), apiKey); + } + + public async Task GetLocationsByQueryAsync(string address) + { if (string.IsNullOrEmpty(address)) { throw new ArgumentNullException(nameof(address)); } - return await this.GetLocationsAsync(FindByQueryApiUrl + Uri.EscapeDataString(address) + "&key=" + apiKey); + return await this.GetLocationsAsync(FindByQueryApiUrl + Uri.EscapeDataString(address) + "&key=" + this.apiKey); } - public async Task GetLocationsByPointAsync(string apiKey, double latitude, double longitude) + public async Task GetLocationsByPointAsync(double latitude, double longitude) { - if (string.IsNullOrEmpty(apiKey)) - { - throw new ArgumentNullException(nameof(apiKey)); - } - return await this.GetLocationsAsync( - string.Format(CultureInfo.InvariantCulture, FindByPointUrl, latitude, longitude) + "&key=" + apiKey); + string.Format(CultureInfo.InvariantCulture, FindByPointUrl, latitude, longitude) + "&key=" + this.apiKey); } - public string GetLocationMapImageUrl(string apiKey, Location location, int? index = null) + public string GetLocationMapImageUrl(Location location, int? index = null) { - if (string.IsNullOrEmpty(apiKey)) - { - throw new ArgumentNullException(nameof(apiKey)); - } - if (location == null) { throw new ArgumentNullException(nameof(location)); @@ -71,7 +64,7 @@ location.BoundaryBox[3], point.Coordinates[0], point.Coordinates[1], index) - + "&key=" + apiKey; + + "&key=" + this.apiKey; } else { diff --git a/CSharp/BotBuilderLocation/Bing/IBingGeoSpatialService.cs b/CSharp/BotBuilderLocation/Bing/IBingGeoSpatialService.cs index 54e9f10..8d2cda1 100644 --- a/CSharp/BotBuilderLocation/Bing/IBingGeoSpatialService.cs +++ b/CSharp/BotBuilderLocation/Bing/IBingGeoSpatialService.cs @@ -10,27 +10,24 @@ /// /// Gets the locations asynchronously. /// - /// The geo spatial service API key. /// The address query. /// The found locations - Task GetLocationsByQueryAsync(string apiKey, string address); + Task GetLocationsByQueryAsync(string address); /// /// Gets the locations asynchronously. /// - /// The geo spatial service API key. /// The point latitude. /// The point longitude. /// The found locations - Task GetLocationsByPointAsync(string apiKey, double latitude, double longitude); + Task GetLocationsByPointAsync(double latitude, double longitude); /// /// Gets the map image URL. /// - /// The geo spatial service API key. /// The location. /// The pin point index. /// - string GetLocationMapImageUrl(string apiKey, Location location, int? index = null); + string GetLocationMapImageUrl(Location location, int? index = null); } } diff --git a/CSharp/BotBuilderLocation/BotBuilderLocation.csproj b/CSharp/BotBuilderLocation/BotBuilderLocation.csproj index a72b75c..61e9b53 100644 --- a/CSharp/BotBuilderLocation/BotBuilderLocation.csproj +++ b/CSharp/BotBuilderLocation/BotBuilderLocation.csproj @@ -86,7 +86,17 @@ - + + + + + + + + + + + diff --git a/CSharp/BotBuilderLocation/Dialogs/AddFavoriteLocationDialog.cs b/CSharp/BotBuilderLocation/Dialogs/AddFavoriteLocationDialog.cs new file mode 100644 index 0000000..9a3bd5b --- /dev/null +++ b/CSharp/BotBuilderLocation/Dialogs/AddFavoriteLocationDialog.cs @@ -0,0 +1,75 @@ +namespace Microsoft.Bot.Builder.Location.Dialogs +{ + using System; + using System.Threading.Tasks; + using Bing; + using Builder.Dialogs; + using Connector; + using Internals.Fibers; + + [Serializable] + internal class AddFavoriteLocationDialog : LocationDialogBase + { + private readonly IFavoritesManager favoritesManager; + private readonly Location location; + + internal AddFavoriteLocationDialog(IFavoritesManager favoritesManager, Location location, LocationResourceManager resourceManager) : base(resourceManager) + { + SetField.NotNull(out this.favoritesManager, nameof(favoritesManager), favoritesManager); + SetField.NotNull(out this.location, nameof(location), location); + } + + public override async Task StartAsync(IDialogContext context) + { + // no capacity to add to favorites in the first place! + // OR the location is already marked as favorite + if (this.favoritesManager.MaxCapacityReached(context) || this.favoritesManager.IsFavorite(context, this.location)) + { + context.Done(new LocationDialogResponse(this.location)); + return; + } + + PromptDialog.Confirm( + context, + async (dialogContext, answer) => + { + if (await answer) + { + await dialogContext.PostAsync(this.ResourceManager.EnterNewFavoriteLocationName); + dialogContext.Wait(this.MessageReceivedAsync); + } + else + { + // The user does NOT want to add the location to favorites. + dialogContext.Done(new LocationDialogResponse(this.location)); + } + }, + this.ResourceManager.AddToFavoritesAsk, + retry: this.ResourceManager.AddToFavoritesRetry, + promptStyle: PromptStyle.None); + } + + /// + /// Runs when we expect the user to enter + /// + /// + /// + /// + protected override async Task MessageReceivedInternalAsync(IDialogContext context, IAwaitable result) + { + var messageText = (await result).Text; + + if (string.IsNullOrWhiteSpace(messageText)) + { + await context.PostAsync(this.ResourceManager.InvalidFavoriteNameResponse); + context.Wait(this.MessageReceivedAsync); + } + else + { + this.favoritesManager.Add(context, new FavoriteLocation { Location = this.location, Name = messageText }); + await context.PostAsync(string.Format(this.ResourceManager.FavoriteAddedConfirmation, messageText)); + context.Done(new LocationDialogResponse(this.location)); + } + } + } +} diff --git a/CSharp/BotBuilderLocation/Dialogs/BranchType.cs b/CSharp/BotBuilderLocation/Dialogs/BranchType.cs new file mode 100644 index 0000000..ea971d1 --- /dev/null +++ b/CSharp/BotBuilderLocation/Dialogs/BranchType.cs @@ -0,0 +1,28 @@ +namespace Microsoft.Bot.Builder.Location.Dialogs +{ + /// + /// Represents different branch (sub dialog) types that can use to achieve its goal. + /// + public enum BranchType + { + /// + /// The branch dialog type for retrieving a location. + /// + LocationRetriever, + + /// + /// The branch dialog type for retrieving a location from a user's favorites. + /// + FavoriteLocationRetriever, + + /// + /// The branch dialog type for saving a location to a user's favorites. + /// + AddToFavorites, + + /// + /// The branch dialog type for editing and retrieving one of the user's existing favorites. + /// + EditFavoriteLocation + } +} diff --git a/CSharp/BotBuilderLocation/Dialogs/EditFavoriteLocationDialog.cs b/CSharp/BotBuilderLocation/Dialogs/EditFavoriteLocationDialog.cs new file mode 100644 index 0000000..5a9c6b4 --- /dev/null +++ b/CSharp/BotBuilderLocation/Dialogs/EditFavoriteLocationDialog.cs @@ -0,0 +1,49 @@ +namespace Microsoft.Bot.Builder.Location.Dialogs +{ + using System; + using System.Threading.Tasks; + using Bing; + using Builder.Dialogs; + using Internals.Fibers; + + [Serializable] + internal class EditFavoriteLocationDialog : LocationDialogBase + { + private readonly ILocationDialogFactory locationDialogFactory; + private readonly IFavoritesManager favoritesManager; + private readonly string favoriteName; + private readonly Location favoriteLocation; + + internal EditFavoriteLocationDialog( + ILocationDialogFactory locationDialogFactory, + IFavoritesManager favoritesManager, + string favoriteName, + Location favoriteLocation, + LocationResourceManager resourceManager) + : base(resourceManager) + { + SetField.NotNull(out this.locationDialogFactory, nameof(locationDialogFactory), locationDialogFactory); + SetField.NotNull(out this.favoritesManager, nameof(favoritesManager), favoritesManager); + SetField.NotNull(out this.favoriteName, nameof(favoriteName), favoriteName); + SetField.NotNull(out this.favoriteLocation, nameof(favoriteLocation), favoriteLocation); + } + + public override async Task StartAsync(IDialogContext context) + { + await context.PostAsync(string.Format(this.ResourceManager.EditFavoritePrompt, this.favoriteName)); + var locationRetrieverDialog = this.locationDialogFactory.CreateDialog(BranchType.LocationRetriever); + context.Call(locationRetrieverDialog, this.ResumeAfterChildDialogAsync); + } + + internal override async Task ResumeAfterChildDialogInternalAsync(IDialogContext context, IAwaitable result) + { + var newLocationValue = (await result).Location; + this.favoritesManager.Update( + context, + currentValue: new FavoriteLocation { Name = this.favoriteName, Location = this.favoriteLocation }, + newValue: new FavoriteLocation { Name = this.favoriteName, Location = newLocationValue}); + await context.PostAsync(string.Format(this.ResourceManager.FavoriteEdittedConfirmation, this.favoriteName)); + context.Done(new LocationDialogResponse(newLocationValue)); + } + } +} diff --git a/CSharp/BotBuilderLocation/Dialogs/FacebookNativeLocationRetrieverDialog.cs b/CSharp/BotBuilderLocation/Dialogs/FacebookNativeLocationRetrieverDialog.cs index 35448bc..8c8b76e 100644 --- a/CSharp/BotBuilderLocation/Dialogs/FacebookNativeLocationRetrieverDialog.cs +++ b/CSharp/BotBuilderLocation/Dialogs/FacebookNativeLocationRetrieverDialog.cs @@ -12,15 +12,19 @@ namespace Microsoft.Bot.Builder.Location.Dialogs using ConnectorEx; [Serializable] - internal class FacebookNativeLocationRetrieverDialog : LocationDialogBase + internal class FacebookNativeLocationRetrieverDialog : LocationRetrieverDialogBase { private readonly string prompt; - public FacebookNativeLocationRetrieverDialog(string prompt, LocationResourceManager resourceManager) - : base(resourceManager) + public FacebookNativeLocationRetrieverDialog( + string prompt, + IGeoSpatialService geoSpatialService, + LocationOptions options, + LocationRequiredFields requiredFields, + LocationResourceManager resourceManager) + : base(geoSpatialService, options, requiredFields, resourceManager) { SetField.NotNull(out this.prompt, nameof(prompt), prompt); - this.prompt = prompt; } public override async Task StartAsync(IDialogContext context) @@ -36,7 +40,7 @@ namespace Microsoft.Bot.Builder.Location.Dialogs if (place != null && place.Geo != null && place.Geo.latitude != null && place.Geo.longitude != null) { - var location = new Bing.Location + var location = new Location { Point = new GeocodePoint { @@ -47,8 +51,8 @@ namespace Microsoft.Bot.Builder.Location.Dialogs } } }; - - context.Done(new LocationDialogResponse(location)); + + await this.ProcessRetrievedLocation(context, location); } else { diff --git a/CSharp/BotBuilderLocation/Dialogs/FavoriteLocation.cs b/CSharp/BotBuilderLocation/Dialogs/FavoriteLocation.cs new file mode 100644 index 0000000..a65b3ee --- /dev/null +++ b/CSharp/BotBuilderLocation/Dialogs/FavoriteLocation.cs @@ -0,0 +1,13 @@ +namespace Microsoft.Bot.Builder.Location.Dialogs +{ + using System; + using Bing; + + [Serializable] + internal class FavoriteLocation + { + public string Name { get; set; } + + public Location Location { get; set; } + } +} diff --git a/CSharp/BotBuilderLocation/Dialogs/FavoriteLocationRetrieverDialog.cs b/CSharp/BotBuilderLocation/Dialogs/FavoriteLocationRetrieverDialog.cs new file mode 100644 index 0000000..12c1986 --- /dev/null +++ b/CSharp/BotBuilderLocation/Dialogs/FavoriteLocationRetrieverDialog.cs @@ -0,0 +1,158 @@ +namespace Microsoft.Bot.Builder.Location.Dialogs +{ + using System; + using System.Collections.Generic; + using System.Linq; + using System.Threading.Tasks; + using Bing; + using Builder.Dialogs; + using Connector; + using Internals.Fibers; + + [Serializable] + class FavoriteLocationRetrieverDialog : LocationRetrieverDialogBase + { + private readonly bool supportsKeyboard; + private readonly IFavoritesManager favoritesManager; + private readonly ILocationDialogFactory locationDialogFactory; + private readonly ILocationCardBuilder cardBuilder; + private IList locations = new List(); + private FavoriteLocation selectedLocation; + + public FavoriteLocationRetrieverDialog( + bool supportsKeyboard, + IFavoritesManager favoritesManager, + ILocationDialogFactory locationDialogFactory, + ILocationCardBuilder cardBuilder, + IGeoSpatialService geoSpatialService, + LocationOptions options, + LocationRequiredFields requiredFields, + LocationResourceManager resourceManager) + : base(geoSpatialService, options, requiredFields, resourceManager) + { + SetField.NotNull(out this.favoritesManager, nameof(favoritesManager), favoritesManager); + SetField.NotNull(out this.locationDialogFactory, nameof(locationDialogFactory), locationDialogFactory); + SetField.NotNull(out this.cardBuilder, nameof(cardBuilder), cardBuilder); + this.supportsKeyboard = supportsKeyboard; + } + + public override async Task StartAsync(IDialogContext context) + { + this.locations = this.favoritesManager.GetFavorites(context); + + if (locations.Count == 0) + { + // The user has no favorite locations + // switch to a normal location retriever dialog + await context.PostAsync(this.ResourceManager.NoFavoriteLocationsFound); + this.SwitchToLocationRetriever(context); + } + else + { + await context.PostAsync(this.CreateFavoritesCarousel(context)); + await context.PostAsync(this.ResourceManager.SelectFavoriteLocationPrompt); + context.Wait(this.MessageReceivedAsync); + } + } + + protected override async Task MessageReceivedInternalAsync(IDialogContext context, IAwaitable result) + { + var messageText = (await result).Text.Trim(); + int value = -1; + string command = null; + + if (StringComparer.OrdinalIgnoreCase.Equals(messageText, this.ResourceManager.OtherComand)) + { + this.SwitchToLocationRetriever(context); + } + else if (this.TryParseSelection(messageText, out value)) + { + await this.ProcessRetrievedLocation(context, this.locations[value - 1].Location); + } + else if (this.TryParseCommandSelection(messageText, out value, out command) && + (StringComparer.OrdinalIgnoreCase.Equals(command, this.ResourceManager.DeleteCommand) + || StringComparer.OrdinalIgnoreCase.Equals(command, this.ResourceManager.EditCommand))) + { + if (StringComparer.OrdinalIgnoreCase.Equals(command, this.ResourceManager.DeleteCommand)) + { + TryConfirmAndDelete(context, this.locations[value - 1]); + } + else + { + var editDialog = this.locationDialogFactory.CreateDialog(BranchType.EditFavoriteLocation, this.locations[value - 1].Location, this.locations[value - 1].Name); + context.Call(editDialog, this.ResumeAfterChildDialogAsync); + } + } + else + { + await context.PostAsync(this.ResourceManager.InvalidFavoriteLocationSelection); + context.Wait(this.MessageReceivedAsync); + } + } + + private void SwitchToLocationRetriever(IDialogContext context) + { + var locationRetrieverDialog = this.locationDialogFactory.CreateDialog(BranchType.LocationRetriever); + context.Call(locationRetrieverDialog, this.ResumeAfterChildDialogAsync); + } + + private IMessageActivity CreateFavoritesCarousel(IDialogContext context) + { + // Get cards for the favorite locations + var attachments = this.cardBuilder.CreateHeroCards(this.locations.Select(f => f.Location).ToList(), alwaysShowNumericPrefix: true, locationNames: this.locations.Select(f => f.Name).ToList()); + var message = context.MakeMessage(); + message.Attachments = attachments.Select(c => c.ToAttachment()).ToList(); + message.AttachmentLayout = AttachmentLayoutTypes.Carousel; + return message; + } + + private bool TryParseSelection(string text, out int value) + { + return int.TryParse(text, out value) && value > 0 && value <= this.locations.Count; + } + + private bool TryParseCommandSelection(string text, out int value, out string command) + { + value = -1; + command = null; + + var tokens = text.Split(' '); + if (tokens.Length != 2) + return false; + + command = tokens[0]; + + return this.TryParseSelection(tokens[1], out value); + } + + private void TryConfirmAndDelete(IDialogContext context, FavoriteLocation favoriteLocation) + { + var confirmationAsk = string.Format( + this.ResourceManager.DeleteFavoriteConfirmationAsk, + $"{favoriteLocation.Name}: {favoriteLocation.Location.GetFormattedAddress(this.ResourceManager.AddressSeparator)}"); + + this.selectedLocation = favoriteLocation; + + PromptDialog.Confirm( + context, + async (dialogContext, answer) => + { + if (await answer) + { + this.favoritesManager.Delete(dialogContext, this.selectedLocation); + await dialogContext.PostAsync(string.Format(this.ResourceManager.FavoriteDeletedConfirmation, this.selectedLocation.Name)); + await this.StartAsync(dialogContext); + } + else + { + await dialogContext.PostAsync(this.ResourceManager.DeleteFavoriteAbortion); + await dialogContext.PostAsync(this.ResourceManager.SelectFavoriteLocationPrompt); + dialogContext.Wait(this.MessageReceivedAsync); + } + }, + confirmationAsk, + retry: this.ResourceManager.ConfirmationInvalidResponse, + promptStyle: PromptStyle.None); + } + } +} diff --git a/CSharp/BotBuilderLocation/Dialogs/ILocationDialogFactory.cs b/CSharp/BotBuilderLocation/Dialogs/ILocationDialogFactory.cs new file mode 100644 index 0000000..e8d4d7e --- /dev/null +++ b/CSharp/BotBuilderLocation/Dialogs/ILocationDialogFactory.cs @@ -0,0 +1,20 @@ +namespace Microsoft.Bot.Builder.Location.Dialogs +{ + using Bing; + using Builder.Dialogs; + + /// + /// Represents the interface that defines how location (sub)dialogs are created. + /// + internal interface ILocationDialogFactory + { + /// + /// Given a branch parameter, creates the appropriate corresponding dialog that should run. + /// + /// The location dialog branch. + /// The location to be passed to the new dialog, if applicable. + /// The location name to be passed to the new dialog, if applicable. + /// The dialog. + IDialog CreateDialog(BranchType branch, Location location = null, string locationName = null); + } +} diff --git a/CSharp/BotBuilderLocation/Dialogs/LocationDialogBase.cs b/CSharp/BotBuilderLocation/Dialogs/LocationDialogBase.cs index bc64f8a..e511830 100644 --- a/CSharp/BotBuilderLocation/Dialogs/LocationDialogBase.cs +++ b/CSharp/BotBuilderLocation/Dialogs/LocationDialogBase.cs @@ -1,7 +1,6 @@ namespace Microsoft.Bot.Builder.Location.Dialogs { using System; - using System.Reflection; using System.Threading.Tasks; using Builder.Dialogs; using Connector; diff --git a/CSharp/BotBuilderLocation/Dialogs/LocationDialogFactory.cs b/CSharp/BotBuilderLocation/Dialogs/LocationDialogFactory.cs index d8fbc11..c7700a4 100644 --- a/CSharp/BotBuilderLocation/Dialogs/LocationDialogFactory.cs +++ b/CSharp/BotBuilderLocation/Dialogs/LocationDialogFactory.cs @@ -3,29 +3,86 @@ using System; using Bing; using Builder.Dialogs; + using Internals.Fibers; - internal static class LocationDialogFactory + [Serializable] + internal class LocationDialogFactory : ILocationDialogFactory { - internal static IDialog CreateLocationRetrieverDialog( + private readonly string apiKey; + private readonly string channelId; + private readonly string prompt; + private readonly LocationOptions options; + private readonly LocationRequiredFields requiredFields; + private readonly IGeoSpatialService geoSpatialService; + private readonly LocationResourceManager resourceManager; + + internal LocationDialogFactory( string apiKey, string channelId, string prompt, - bool useNativeControl, + IGeoSpatialService geoSpatialService, + LocationOptions options, + LocationRequiredFields requiredFields, LocationResourceManager resourceManager) { - bool isFacebookChannel = StringComparer.OrdinalIgnoreCase.Equals(channelId, "facebook"); + SetField.NotNull(out this.apiKey, nameof(apiKey), apiKey); + SetField.NotNull(out this.channelId, nameof(channelId), channelId); + SetField.NotNull(out this.prompt, nameof(prompt), prompt); + this.geoSpatialService = geoSpatialService; + this.options = options; + this.requiredFields = requiredFields; + this.resourceManager = resourceManager; + } - if (useNativeControl && isFacebookChannel) + public IDialog CreateDialog(BranchType branch, Location location = null, string locationName = null) + { + bool isFacebookChannel = StringComparer.OrdinalIgnoreCase.Equals(this.channelId, "facebook"); + + if (branch == BranchType.LocationRetriever) { - return new FacebookNativeLocationRetrieverDialog(prompt, resourceManager); - } + if (this.options.HasFlag(LocationOptions.UseNativeControl) && isFacebookChannel) + { + return new FacebookNativeLocationRetrieverDialog( + this.prompt, + this.geoSpatialService, + this.options, + this.requiredFields, + this.resourceManager); + } - return new RichLocationRetrieverDialog( - geoSpatialService: new BingGeoSpatialService(), - apiKey: apiKey, - prompt: prompt, - supportsKeyboard: isFacebookChannel, - resourceManager: resourceManager); + return new RichLocationRetrieverDialog( + prompt: this.prompt, + supportsKeyboard: isFacebookChannel, + cardBuilder: new LocationCardBuilder(this.apiKey), + geoSpatialService: new BingGeoSpatialService(this.apiKey), + options: this.options, + requiredFields: this.requiredFields, + resourceManager: this.resourceManager); + } + else if (branch == BranchType.FavoriteLocationRetriever) + { + return new FavoriteLocationRetrieverDialog( + isFacebookChannel, + new FavoritesManager(), + this, + new LocationCardBuilder(this.apiKey), + new BingGeoSpatialService(this.apiKey), + this.options, + this.requiredFields, + this.resourceManager); + } + else if (branch == BranchType.AddToFavorites) + { + return new AddFavoriteLocationDialog(new FavoritesManager(), location, this.resourceManager); + } + else if (branch == BranchType.EditFavoriteLocation) + { + return new EditFavoriteLocationDialog(this, new FavoritesManager(), locationName, location, this.resourceManager); + } + else + { + throw new ArgumentException("Invalid branch value."); + } } } } diff --git a/CSharp/BotBuilderLocation/Dialogs/LocationRetrieverDialogBase.cs b/CSharp/BotBuilderLocation/Dialogs/LocationRetrieverDialogBase.cs new file mode 100644 index 0000000..ef857a9 --- /dev/null +++ b/CSharp/BotBuilderLocation/Dialogs/LocationRetrieverDialogBase.cs @@ -0,0 +1,88 @@ +namespace Microsoft.Bot.Builder.Location.Dialogs +{ + using System; + using System.Linq; + using System.Threading.Tasks; + using Bing; + using Builder.Dialogs; + using Internals.Fibers; + + [Serializable] + abstract class LocationRetrieverDialogBase : LocationDialogBase + { + protected readonly IGeoSpatialService geoSpatialService; + + private readonly LocationOptions options; + private readonly LocationRequiredFields requiredFields; + private Location selectedLocation; + + internal LocationRetrieverDialogBase( + IGeoSpatialService geoSpatialService, + LocationOptions options, + LocationRequiredFields requiredFields, + LocationResourceManager resourceManager) : base(resourceManager) + { + SetField.NotNull(out this.geoSpatialService, nameof(geoSpatialService), geoSpatialService); + this.options = options; + this.requiredFields = requiredFields; + } + + protected async Task ProcessRetrievedLocation(IDialogContext context, Location retrievedLocation) + { + this.selectedLocation = retrievedLocation; + await this.TryReverseGeocodeAddress(this.selectedLocation); + + if (this.requiredFields != LocationRequiredFields.None) + { + var requiredDialog = new LocationRequiredFieldsDialog(this.selectedLocation, this.requiredFields, this.ResourceManager); + context.Call(requiredDialog, this.ResumeAfterChildDialogAsync); + } + else + { + context.Done(new LocationDialogResponse(this.selectedLocation)); + } + } + + /// + /// Resumes after a required fields dialog returns (in case of the rich and Facebook retrievers). + /// Resumes after a location retriever dialog returns or an edit favorite location dialog returns (in case of the favorite retriever). + /// + /// The context. + /// The result. + /// The asynchronous task. + internal override async Task ResumeAfterChildDialogInternalAsync(IDialogContext context, IAwaitable result) + { + context.Done(new LocationDialogResponse((await result).Location)); + } + + /// + /// Tries to complete missing fields using Bing reverse geo-coder. + /// + /// The location. + /// The asynchronous task. + private async Task TryReverseGeocodeAddress(Location location) + { + // If user passed ReverseGeocode flag and dialog returned a geo point, + // then try to reverse geocode it using BingGeoSpatialService. + if (this.options.HasFlag(LocationOptions.ReverseGeocode) && location != null && location.Address == null && location.Point != null) + { + var results = await this.geoSpatialService.GetLocationsByPointAsync(location.Point.Coordinates[0], location.Point.Coordinates[1]); + var geocodedLocation = results?.Locations?.FirstOrDefault(); + if (geocodedLocation?.Address != null) + { + // We don't trust reverse geo-coder on the street address level, + // so copy all fields except it. + // TODO: do we need to check the returned confidence level? + location.Address = new Bing.Address + { + CountryRegion = geocodedLocation.Address.CountryRegion, + AdminDistrict = geocodedLocation.Address.AdminDistrict, + AdminDistrict2 = geocodedLocation.Address.AdminDistrict2, + Locality = geocodedLocation.Address.Locality, + PostalCode = geocodedLocation.Address.PostalCode + }; + } + } + } + } +} diff --git a/CSharp/BotBuilderLocation/Dialogs/RichLocationRetrieverDialog.cs b/CSharp/BotBuilderLocation/Dialogs/RichLocationRetrieverDialog.cs index 81cb02c..9cc66a9 100644 --- a/CSharp/BotBuilderLocation/Dialogs/RichLocationRetrieverDialog.cs +++ b/CSharp/BotBuilderLocation/Dialogs/RichLocationRetrieverDialog.cs @@ -7,36 +7,40 @@ using Bing; using Builder.Dialogs; using Connector; + using ConnectorEx; using Internals.Fibers; [Serializable] - class RichLocationRetrieverDialog : LocationDialogBase + class RichLocationRetrieverDialog : LocationRetrieverDialogBase { private const int MaxLocationCount = 5; private readonly string prompt; private readonly bool supportsKeyboard; private readonly List locations = new List(); - private readonly IGeoSpatialService geoSpatialService; - private readonly string apiKey; + private readonly ILocationCardBuilder cardBuilder; /// /// Initializes a new instance of the class. /// - /// The Geo-Special Service - /// The geo spatial service API key. + /// The Geo-Special Service. + /// The card builder service. /// The prompt posted to the user when dialog starts. /// Indicates whether channel supports keyboard buttons or not. + /// The location options used to customize the experience. + /// The location required fields. /// The resource manager. internal RichLocationRetrieverDialog( - IGeoSpatialService geoSpatialService, - string apiKey, string prompt, bool supportsKeyboard, - LocationResourceManager resourceManager) : base(resourceManager) + ILocationCardBuilder cardBuilder, + IGeoSpatialService geoSpatialService, + LocationOptions options, + LocationRequiredFields requiredFields, + LocationResourceManager resourceManager) + : base(geoSpatialService, options, requiredFields, resourceManager) { - SetField.NotNull(out this.geoSpatialService, nameof(geoSpatialService), geoSpatialService); - SetField.NotNull(out this.apiKey, nameof(apiKey), apiKey); - this.prompt = prompt; + SetField.NotNull(out this.cardBuilder, nameof(cardBuilder), cardBuilder); + SetField.NotNull(out this.prompt, nameof(prompt), prompt); this.supportsKeyboard = supportsKeyboard; } @@ -55,12 +59,13 @@ { await this.TryResolveAddressAsync(context, message); } - else if (!this.TryResolveAddressSelectionAsync(context, message)) + else if (!(await this.TryResolveAddressSelectionAsync(context, message))) { await context.PostAsync(this.ResourceManager.InvalidLocationResponse); context.Wait(this.MessageReceivedAsync); } } + /// /// Tries to resolve address by passing the test to the Bing Geo-Spatial API /// and looking for returned locations. @@ -70,7 +75,7 @@ /// The asynchronous task. private async Task TryResolveAddressAsync(IDialogContext context, IMessageActivity message) { - var locationSet = await this.geoSpatialService.GetLocationsByQueryAsync(this.apiKey, message.Text); + var locationSet = await this.geoSpatialService.GetLocationsByQueryAsync(message.Text); var foundLocations = locationSet?.Locations; if (foundLocations == null || foundLocations.Count == 0) @@ -84,7 +89,7 @@ this.locations.AddRange(foundLocations.Take(MaxLocationCount)); var locationsCardReply = context.MakeMessage(); - locationsCardReply.Attachments = LocationCard.CreateLocationHeroCard(this.apiKey, this.locations); + locationsCardReply.Attachments = this.cardBuilder.CreateHeroCards(this.locations).Select(C => C.ToAttachment()).ToList(); locationsCardReply.AttachmentLayout = AttachmentLayoutTypes.Carousel; await context.PostAsync(locationsCardReply); @@ -105,19 +110,19 @@ /// The context. /// The message. /// The asynchronous task. - private bool TryResolveAddressSelectionAsync(IDialogContext context, IMessageActivity message) + private async Task TryResolveAddressSelectionAsync(IDialogContext context, IMessageActivity message) { int value; if (int.TryParse(message.Text, out value) && value > 0 && value <= this.locations.Count) { - context.Done(new LocationDialogResponse(this.locations[value - 1])); + await this.ProcessRetrievedLocation(context, this.locations[value - 1]); return true; } if (StringComparer.OrdinalIgnoreCase.Equals(message.Text, this.ResourceManager.OtherComand)) { // Return new empty location to be filled by the required fields dialog. - context.Done(new LocationDialogResponse(new Location())); + await this.ProcessRetrievedLocation(context, new Location()); return true; } @@ -140,7 +145,7 @@ { if (await answer) { - dialogContext.Done(new LocationDialogResponse(this.locations.First())); + await this.ProcessRetrievedLocation(dialogContext, this.locations.First()); } else { @@ -162,8 +167,9 @@ { if (this.supportsKeyboard) { + var keyboardCard = this.cardBuilder.CreateKeyboardCard(this.ResourceManager.MultipleResultsFound, this.locations.Count); var keyboardCardReply = context.MakeMessage(); - keyboardCardReply.Attachments = LocationCard.CreateLocationKeyboardCard(this.locations, this.ResourceManager.MultipleResultsFound); + keyboardCardReply.Attachments = new List { keyboardCard.ToAttachment() }; keyboardCardReply.AttachmentLayout = AttachmentLayoutTypes.List; await context.PostAsync(keyboardCardReply); } diff --git a/CSharp/BotBuilderLocation/FavoritesManager.cs b/CSharp/BotBuilderLocation/FavoritesManager.cs new file mode 100644 index 0000000..339af02 --- /dev/null +++ b/CSharp/BotBuilderLocation/FavoritesManager.cs @@ -0,0 +1,98 @@ +namespace Microsoft.Bot.Builder.Location +{ + using System; + using System.Collections.Generic; + using System.Linq; + using Bing; + using Builder.Dialogs.Internals; + using Dialogs; + + [Serializable] + internal class FavoritesManager : IFavoritesManager + { + private const string FavoritesKey = "favorites"; + private const int MaxFavoriteCount = 5; + + public bool MaxCapacityReached(IBotData botData) + { + return this.GetFavorites(botData).Count >= MaxFavoriteCount; + } + + public bool IsFavorite(IBotData botData, Location location) + { + var favorites = this.GetFavorites(botData); + return favorites.Any(favoriteLocation => AreEqual(location, favoriteLocation.Location)); + } + + public void Add(IBotData botData, FavoriteLocation value) + { + var favorites = this.GetFavorites(botData); + + if (favorites.Count >= MaxFavoriteCount) + { + throw new InvalidOperationException("The max allowed number of favorite locations has already been reached."); + } + + favorites.Add(value); + botData.UserData.SetValue(FavoritesKey, favorites); + } + + public void Delete(IBotData botData, FavoriteLocation value) + { + var favorites = this.GetFavorites(botData); + var newFavorites = new List(); + + foreach (var favoriteItem in favorites) + { + if (!AreEqual(favoriteItem.Location, value.Location)) + { + newFavorites.Add(favoriteItem); + } + } + + botData.UserData.SetValue(FavoritesKey, newFavorites); + } + + public void Update(IBotData botData, FavoriteLocation currentValue, FavoriteLocation newValue) + { + var favorites = this.GetFavorites(botData); + var newFavorites = new List(); + + foreach (var item in favorites) + { + if (AreEqual(item.Location, currentValue.Location)) + { + newFavorites.Add(newValue); + } + else + { + newFavorites.Add(item); + } + } + + botData.UserData.SetValue(FavoritesKey, newFavorites); + } + + public IList GetFavorites(IBotData botData) + { + List favorites; + + if (!botData.UserData.TryGetValue(FavoritesKey, out favorites)) + { + // User currently has no favorite locations. Return an empty list. + favorites = new List(); + } + + return favorites; + } + + private static bool AreEqual(Location x, Location y) + { + // Other attributes of a location such as its Confidence, BoundaryBox, etc + // should not be considered as distinguishing factors. + // On the other hand, attributes of a location that are shown to the users + // are what distinguishes one location from another. + return x.GetFormattedAddress(",") == y.GetFormattedAddress(","); + } + } +} diff --git a/CSharp/BotBuilderLocation/IFavoritesManager.cs b/CSharp/BotBuilderLocation/IFavoritesManager.cs new file mode 100644 index 0000000..6284256 --- /dev/null +++ b/CSharp/BotBuilderLocation/IFavoritesManager.cs @@ -0,0 +1,64 @@ +namespace Microsoft.Bot.Builder.Location +{ + using System.Collections.Generic; + using Bing; + using Builder.Dialogs.Internals; + using Dialogs; + + /// + /// Represents the interface that defines how the will + /// store and retrieve favorite locations for its users. + /// + interface IFavoritesManager + { + /// + /// Gets whether the max allowed favorite location count has been reached. + /// + /// The bot data. + /// True if the maximum capacity has been reached, false otherwise. + bool MaxCapacityReached(IBotData botData); + + /// + /// Checks whether the given location is one of the favorite locations of the + /// user inferred from the bot data. + /// + /// The bot data. + /// + /// + bool IsFavorite(IBotData botData, Location location); + + /// + /// Looks up the favorite locations value for the user inferred from the + /// bot data. + /// + /// The bot data. + /// >A list of favorite locations. + IList GetFavorites(IBotData botData); + + /// + /// Adds the given location to the favorites of the user inferred from the + /// bot data. + /// + /// The bot data. + /// The new favorite location value. + void Add(IBotData botData, FavoriteLocation value); + + /// + /// Deletes the given location from the favorites of the user inferred from the + /// bot data. + /// + /// The bot data. + /// The favorite location value to be deleted. + void Delete(IBotData botData, FavoriteLocation value); + + /// + /// Updates the favorites of the user inferred from the bot data. + /// The favorite location whose value matched the given current value is updated + /// to the given new value. + /// + /// The bot data. + /// The updated favorite location value. + /// The updated favorite location value. + void Update(IBotData botData, FavoriteLocation currentValue, FavoriteLocation newValue); + } +} diff --git a/CSharp/BotBuilderLocation/ILocationCardBuilder.cs b/CSharp/BotBuilderLocation/ILocationCardBuilder.cs new file mode 100644 index 0000000..f42ff4e --- /dev/null +++ b/CSharp/BotBuilderLocation/ILocationCardBuilder.cs @@ -0,0 +1,14 @@ +namespace Microsoft.Bot.Builder.Location +{ + using System.Collections.Generic; + using Bing; + using Connector; + using ConnectorEx; + + internal interface ILocationCardBuilder + { + IEnumerable CreateHeroCards(IList locations, bool alwaysShowNumericPrefix = false, IList locationNames = null); + + KeyboardCard CreateKeyboardCard(string selectText, int optionCount = 0, params string[] additionalLabels); + } +} diff --git a/CSharp/BotBuilderLocation/LocationCard.cs b/CSharp/BotBuilderLocation/LocationCard.cs deleted file mode 100644 index 58f2de3..0000000 --- a/CSharp/BotBuilderLocation/LocationCard.cs +++ /dev/null @@ -1,73 +0,0 @@ -namespace Microsoft.Bot.Builder.Location -{ - using System.Collections.Generic; - using System.Linq; - using Bing; - using Connector; - using ConnectorEx; - - /// - /// A static class for creating location cards. - /// - public static class LocationCard - { - /// - /// Creates locations hero cards (carousel). - /// - /// The geo spatial API key. - /// List of the locations. - /// The locations card as attachments. - public static List CreateLocationHeroCard(string apiKey, IList locations) - { - var attachments = new List(); - - int i = 1; - - foreach (var location in locations) - { - string address = locations.Count > 1 ? $"{i}. {location.Address.FormattedAddress}" : location.Address.FormattedAddress; - - var heroCard = new HeroCard - { - Subtitle = address - }; - - if (location.Point != null) - { - var image = - new CardImage( - url: new BingGeoSpatialService().GetLocationMapImageUrl(apiKey, location, i)); - - heroCard.Images = new[] { image }; - } - - attachments.Add(heroCard.ToAttachment()); - - i++; - } - - return attachments; - } - - /// - /// Creates location keyboard cards (buttons). - /// - /// The list of locations. - /// The card prompt. - /// The keyboard cards. - public static List CreateLocationKeyboardCard(IEnumerable locations, string selectText) - { - int i = 1; - var keyboardCard = new KeyboardCard( - selectText, - locations.Select(a => new CardAction - { - Type = "imBack", - Title = i.ToString(), - Value = (i++).ToString() - }).ToList()); - - return new List { keyboardCard.ToAttachment() }; - } - } -} diff --git a/CSharp/BotBuilderLocation/LocationCardBuilder.cs b/CSharp/BotBuilderLocation/LocationCardBuilder.cs new file mode 100644 index 0000000..48c252e --- /dev/null +++ b/CSharp/BotBuilderLocation/LocationCardBuilder.cs @@ -0,0 +1,98 @@ +namespace Microsoft.Bot.Builder.Location +{ + using System; + using System.Collections.Generic; + using System.Linq; + using Bing; + using Builder.Dialogs; + using Connector; + using ConnectorEx; + using Internals.Fibers; + + /// + /// A class for creating location cards. + /// + [Serializable] + public class LocationCardBuilder : ILocationCardBuilder + { + private readonly string apiKey; + + /// + /// Initializes a new instance of the class. + /// + /// The geo spatial API key. + public LocationCardBuilder(string apiKey) + { + SetField.NotNull(out this.apiKey, nameof(apiKey), apiKey); + } + + /// + /// Creates locations hero cards. + /// + /// List of the locations. + /// Indicates whether a list containing exactly one location should have a '1.' prefix in its label. + /// List of strings that can be used as names or labels for the locations. + /// The locations card as a list. + public IEnumerable CreateHeroCards(IList locations, bool alwaysShowNumericPrefix = false, IList locationNames = null) + { + var cards = new List(); + + int i = 1; + + foreach (var location in locations) + { + string nameString = locationNames == null ? string.Empty : $"{locationNames[i-1]}: "; + string locationString = $"{nameString}{location.Address.FormattedAddress}"; + string address = alwaysShowNumericPrefix || locations.Count > 1 ? $"{i}. {locationString}" : locationString; + + var heroCard = new HeroCard + { + Subtitle = address + }; + + if (location.Point != null) + { + var image = + new CardImage( + url: new BingGeoSpatialService(this.apiKey).GetLocationMapImageUrl(location, i)); + + heroCard.Images = new[] { image }; + } + + cards.Add(heroCard); + + i++; + } + + return cards; + } + + /// + /// Creates a location keyboard card (buttons) with numbers and/or additional labels. + /// + /// The card prompt. + /// The number of options for which buttons should be made. + /// Additional buttons labels. + /// The keyboard card. + public KeyboardCard CreateKeyboardCard(string selectText, int optionCount = 0, params string[] additionalLabels) + { + var combinedLabels = new List(); + combinedLabels.AddRange(Enumerable.Range(1, optionCount).Select(i => i.ToString())); + combinedLabels.AddRange(additionalLabels); + + var buttons = new List(); + + foreach (var label in combinedLabels) + { + buttons.Add(new CardAction + { + Type = "imBack", + Title = label, + Value = label + }); + } + + return new KeyboardCard(selectText, buttons); + } + } +} diff --git a/CSharp/BotBuilderLocation/LocationDialog.cs b/CSharp/BotBuilderLocation/LocationDialog.cs index 8337375..d953359 100644 --- a/CSharp/BotBuilderLocation/LocationDialog.cs +++ b/CSharp/BotBuilderLocation/LocationDialog.cs @@ -1,7 +1,7 @@ namespace Microsoft.Bot.Builder.Location { using System; - using System.Linq; + using System.Collections.Generic; using System.Threading.Tasks; using Bing; using Builder.Dialogs; @@ -99,13 +99,9 @@ [Serializable] public sealed class LocationDialog : LocationDialogBase { - private readonly string prompt; - private readonly string channelId; private readonly LocationOptions options; - private readonly LocationRequiredFields requiredFields; - private readonly IGeoSpatialService geoSpatialService; - private readonly string apiKey; - private bool requiredDialogCalled; + private readonly ILocationDialogFactory locationDialogFactory; + private bool selectedLocationConfirmed; private Location selectedLocation; /// @@ -133,35 +129,21 @@ LocationOptions options = LocationOptions.None, LocationRequiredFields requiredFields = LocationRequiredFields.None, LocationResourceManager resourceManager = null) - : this(apiKey, channelId, prompt, new BingGeoSpatialService(), options, requiredFields, resourceManager) + : this(new LocationDialogFactory(apiKey, channelId, prompt, new BingGeoSpatialService(apiKey), options, requiredFields, resourceManager), resourceManager) { + this.options = options; } /// /// Initializes a new instance of the class. /// - /// The geo spatial API key. - /// The channel identifier. - /// The prompt posted to the user when dialog starts. - /// The geo spatial location service. - /// The location options used to customize the experience. - /// The location required fields. + /// The location dialog factory service. /// The location resource manager. internal LocationDialog( - string apiKey, - string channelId, - string prompt, - IGeoSpatialService geoSpatialService, - LocationOptions options = LocationOptions.None, - LocationRequiredFields requiredFields = LocationRequiredFields.None, + ILocationDialogFactory locationDialogFactory, LocationResourceManager resourceManager = null) : base(resourceManager) { - SetField.NotNull(out this.apiKey, nameof(apiKey), apiKey); - SetField.NotNull(out this.prompt, nameof(prompt), prompt); - SetField.NotNull(out this.channelId, nameof(channelId), channelId); - SetField.NotNull(out this.geoSpatialService, nameof(geoSpatialService), geoSpatialService); - this.options = options; - this.requiredFields = requiredFields; + SetField.NotNull(out this.locationDialogFactory, nameof(locationDialogFactory), locationDialogFactory); } #pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously @@ -173,95 +155,113 @@ public override async Task StartAsync(IDialogContext context) #pragma warning restore CS1998 // Async method lacks 'await' operators and will run synchronously { - this.requiredDialogCalled = false; + this.selectedLocationConfirmed = false; - var dialog = LocationDialogFactory.CreateLocationRetrieverDialog( - this.apiKey, - this.channelId, - this.prompt, - this.options.HasFlag(LocationOptions.UseNativeControl), - this.ResourceManager); + if (this.options.HasFlag(LocationOptions.SkipFavorites)) + { + // this is the default branch + this.StartBranch(context, BranchType.LocationRetriever); + } + else + { + // TODO : Should we always start this way even if the user currently has no fav locations? + await context.PostAsync(this.CreateDialogStartHeroCard(context)); + context.Wait(this.MessageReceivedAsync); + } + } + private void StartBranch(IDialogContext context, BranchType branch) + { + var dialog = this.locationDialogFactory.CreateDialog(branch); context.Call(dialog, this.ResumeAfterChildDialogAsync); } + protected override async Task MessageReceivedInternalAsync(IDialogContext context, IAwaitable result) + { + var messageText = (await result).Text.Trim(); + + if (messageText == this.ResourceManager.FavoriteLocations) + { + this.StartBranch(context, BranchType.FavoriteLocationRetriever); + } + else if (messageText == this.ResourceManager.OtherLocation) + { + this.StartBranch(context, BranchType.LocationRetriever); + } + else + { + await context.PostAsync(this.ResourceManager.InvalidStartBranchResponse); + context.Wait(this.MessageReceivedAsync); + } + } + /// - /// Resumes after native location dialog returns. + /// Resumes after: + /// 1- Any of the location retriever dialogs returns. + /// Or + /// 2- The add to favoritesDialog returns. /// /// The context. /// The result. /// The asynchronous task. internal override async Task ResumeAfterChildDialogInternalAsync(IDialogContext context, IAwaitable result) { - this.selectedLocation = (await result).Location; - - await this.TryReverseGeocodeAddress(this.selectedLocation); - - if (!this.requiredDialogCalled && this.requiredFields != LocationRequiredFields.None) + if (this.selectedLocationConfirmed) // resuming after the add to favorites child { - this.requiredDialogCalled = true; - var requiredDialog = new LocationRequiredFieldsDialog(this.selectedLocation, this.requiredFields, this.ResourceManager); - context.Call(requiredDialog, this.ResumeAfterChildDialogAsync); + context.Done(CreatePlace(this.selectedLocation)); } - else + else // resuming after the retriever child { + this.selectedLocation = (await result).Location; + if (this.options.HasFlag(LocationOptions.SkipFinalConfirmation)) { - context.Done(CreatePlace(this.selectedLocation)); - return; + this.selectedLocationConfirmed = true; + this.OfferAddToFavorites(context); + } + else + { + this.MakeFinalConfirmation(context); } - - var confirmationAsk = string.Format( - this.ResourceManager.ConfirmationAsk, - this.selectedLocation.GetFormattedAddress(this.ResourceManager.AddressSeparator)); - - PromptDialog.Confirm( - context, - async (dialogContext, answer) => - { - if (await answer) - { - dialogContext.Done(CreatePlace(this.selectedLocation)); - } - else - { - await dialogContext.PostAsync(this.ResourceManager.ResetPrompt); - await this.StartAsync(dialogContext); - } - }, - confirmationAsk, - retry: this.ResourceManager.ConfirmationInvalidResponse, - promptStyle: PromptStyle.None); } } - /// - /// Tries to complete missing fields using Bing reverse geo-coder. - /// - /// The location. - /// The asynchronous task. - private async Task TryReverseGeocodeAddress(Location location) + private void MakeFinalConfirmation(IDialogContext context) { - // If user passed ReverseGeocode flag and dialog returned a geo point, - // then try to reverse geocode it using BingGeoSpatialService. - if (this.options.HasFlag(LocationOptions.ReverseGeocode) && location != null && location.Address == null && location.Point != null) - { - var results = await this.geoSpatialService.GetLocationsByPointAsync(this.apiKey, location.Point.Coordinates[0], location.Point.Coordinates[1]); - var geocodedLocation = results?.Locations?.FirstOrDefault(); - if (geocodedLocation?.Address != null) - { - // We don't trust reverse geo-coder on the street address level, - // so copy all fields except it. - // TODO: do we need to check the returned confidence level? - location.Address = new Bing.Address + var confirmationAsk = string.Format( + this.ResourceManager.ConfirmationAsk, + this.selectedLocation.GetFormattedAddress(this.ResourceManager.AddressSeparator)); + + PromptDialog.Confirm( + context, + async (dialogContext, answer) => { - CountryRegion = geocodedLocation.Address.CountryRegion, - AdminDistrict = geocodedLocation.Address.AdminDistrict, - AdminDistrict2 = geocodedLocation.Address.AdminDistrict2, - Locality = geocodedLocation.Address.Locality, - PostalCode = geocodedLocation.Address.PostalCode - }; - } + if (await answer) + { + this.selectedLocationConfirmed = true; + this.OfferAddToFavorites(dialogContext); + } + else + { + await dialogContext.PostAsync(this.ResourceManager.ResetPrompt); + await this.StartAsync(dialogContext); + } + }, + confirmationAsk, + retry: this.ResourceManager.ConfirmationInvalidResponse, + promptStyle: PromptStyle.None); + } + + private void OfferAddToFavorites(IDialogContext context) + { + if (!this.options.HasFlag(LocationOptions.SkipFavorites)) + { + var addToFavoritesDialog = this.locationDialogFactory.CreateDialog(BranchType.AddToFavorites, this.selectedLocation); + context.Call(addToFavoritesDialog, this.ResumeAfterChildDialogAsync); + } + else + { + context.Done(CreatePlace(this.selectedLocation)); } } @@ -302,5 +302,34 @@ return place; } + + private IMessageActivity CreateDialogStartHeroCard(IDialogContext context) + { + var dialogStartCard = context.MakeMessage(); + var buttons = new List(); + + var branches = new string[] { this.ResourceManager.FavoriteLocations, this.ResourceManager.OtherLocation }; + + foreach (var possibleBranch in branches) + { + buttons.Add(new CardAction + { + Type = "imBack", + Title = possibleBranch, + Value = possibleBranch + }); + } + + var heroCard = new HeroCard + { + Subtitle = this.ResourceManager.DialogStartBranchAsk, + Buttons = buttons + }; + + dialogStartCard.Attachments = new List { heroCard.ToAttachment() }; + dialogStartCard.AttachmentLayout = AttachmentLayoutTypes.Carousel; + + return dialogStartCard; + } } } \ No newline at end of file diff --git a/CSharp/BotBuilderLocation/LocationOptions.cs b/CSharp/BotBuilderLocation/LocationOptions.cs index 12dd7cc..d491d02 100644 --- a/CSharp/BotBuilderLocation/LocationOptions.cs +++ b/CSharp/BotBuilderLocation/LocationOptions.cs @@ -27,16 +27,22 @@ /// but still want the control to return to you a full address. /// /// - /// Due to the accuracy of reverse geo-coders, we only use it to capture + /// Due to the accuracy limitations of reverse geo-coders, we only use it to capture /// , , /// , and /// ReverseGeocode = 2, - /// + /// + /// Use this option if you do not want the LocationDialog to offer + /// keeping track of the user's favorite locations. + /// + SkipFavorites = 4, + + /// /// Use this option if you want the location dialog to skip the final /// confirmation before returning the location /// - SkipFinalConfirmation = 8 + SkipFinalConfirmation = 8 } } \ No newline at end of file diff --git a/CSharp/BotBuilderLocation/LocationResourceManager.cs b/CSharp/BotBuilderLocation/LocationResourceManager.cs index 92eeaa2..6afbd28 100644 --- a/CSharp/BotBuilderLocation/LocationResourceManager.cs +++ b/CSharp/BotBuilderLocation/LocationResourceManager.cs @@ -14,120 +14,25 @@ { private readonly ResourceManager resourceManager; - /// - /// The resource string. - /// - public virtual string Country => this.GetResource(nameof(Strings.Country)); - - /// - /// The resource string. - /// - public virtual string Locality => this.GetResource(nameof(Strings.Locality)); - - /// - /// The resource string. - /// - public virtual string PostalCode => this.GetResource(nameof(Strings.PostalCode)); - - /// - /// The resource string. - /// - public virtual string Region => this.GetResource(nameof(Strings.Region)); - - /// - /// The resource string. - /// - public virtual string StreetAddress => this.GetResource(nameof(Strings.StreetAddress)); - - /// - /// The resource string. - /// - public virtual string CancelCommand => this.GetResource(nameof(Strings.CancelCommand)); - - /// - /// The resource string. - /// - public virtual string HelpCommand => this.GetResource(nameof(Strings.HelpCommand)); - - /// - /// The resource string. - /// - public virtual string HelpMessage => this.GetResource(nameof(Strings.HelpMessage)); - - /// - /// The resource string. - /// - public virtual string InvalidLocationResponse => this.GetResource(nameof(Strings.InvalidLocationResponse)); - - /// - /// The resource string. - /// - public virtual string InvalidLocationResponseFacebook => this.GetResource(nameof(Strings.InvalidLocationResponseFacebook)); - - /// - /// The resource string. - /// - public virtual string LocationNotFound => this.GetResource(nameof(Strings.LocationNotFound)); - - /// - /// The resource string. - /// - public virtual string MultipleResultsFound => this.GetResource(nameof(Strings.MultipleResultsFound)); - - /// - /// The resource string. - /// - public virtual string ResetCommand => this.GetResource(nameof(Strings.ResetCommand)); - - /// - /// The resource string. - /// - public virtual string ResetPrompt => this.GetResource(nameof(Strings.ResetPrompt)); - - /// - /// The resource string. - /// - public virtual string CancelPrompt => this.GetResource(nameof(Strings.CancelPrompt)); - - /// - /// The resource string. - /// - public virtual string SelectLocation => this.GetResource(nameof(Strings.SelectLocation)); - - /// - /// The resource string. - /// - public virtual string SingleResultFound => this.GetResource(nameof(Strings.SingleResultFound)); - - /// - /// The resource string. - /// - public virtual string TitleSuffix => this.GetResource(nameof(Strings.TitleSuffix)); - - /// - /// The resource string. - /// - public virtual string TitleSuffixFacebook => this.GetResource(nameof(Strings.TitleSuffixFacebook)); - - /// - /// The resource string. - /// - public virtual string ConfirmationAsk => this.GetResource(nameof(Strings.ConfirmationAsk)); - /// /// The resource string. /// public virtual string AddressSeparator => this.GetResource(nameof(Strings.AddressSeparator)); /// - /// The resource string. + /// The resource string. /// - public virtual string OtherComand => this.GetResource(nameof(Strings.OtherComand)); + public virtual string AddToFavoritesAsk => this.GetResource(nameof(Strings.AddToFavoritesAsk)); /// - /// The resource string. + /// The resource string. /// - public virtual string ConfirmationInvalidResponse => this.GetResource(nameof(Strings.ConfirmationInvalidResponse)); + public virtual string AddToFavoritesRetry => this.GetResource(nameof(Strings.AddToFavoritesRetry)); + + /// + /// The resource string. + /// + public virtual string AskForEmptyAddressTemplate => this.GetResource(nameof(Strings.AskForEmptyAddressTemplate)); /// /// The resource string. @@ -140,9 +45,200 @@ public virtual string AskForTemplate => this.GetResource(nameof(Strings.AskForTemplate)); /// - /// The resource string. + /// The resource string. /// - public virtual string AskForEmptyAddressTemplate => this.GetResource(nameof(Strings.AskForEmptyAddressTemplate)); + public virtual string CancelCommand => this.GetResource(nameof(Strings.CancelCommand)); + + /// + /// The resource string. + /// + public virtual string CancelPrompt => this.GetResource(nameof(Strings.CancelPrompt)); + + /// + /// The resource string. + /// + public virtual string ConfirmationAsk => this.GetResource(nameof(Strings.ConfirmationAsk)); + + /// + /// The resource string. + /// + public virtual string ConfirmationInvalidResponse => this.GetResource(nameof(Strings.ConfirmationInvalidResponse)); + + /// + /// The resource string. + /// + public virtual string Country => this.GetResource(nameof(Strings.Country)); + + /// + /// The resource string. + /// + public virtual string DeleteCommand => this.GetResource(nameof(Strings.DeleteCommand)); + + /// + /// The resource string. + /// + public virtual string DeleteFavoriteAbortion => this.GetResource(nameof(Strings.DeleteFavoriteAbortion)); + + /// + /// The resource string. + /// + public virtual string DeleteFavoriteConfirmationAsk => this.GetResource(nameof(Strings.DeleteFavoriteConfirmationAsk)); + + /// + /// The resource string. + /// + public virtual string DialogStartBranchAsk => this.GetResource(nameof(Strings.DialogStartBranchAsk)); + + /// + /// The resource string. + /// + public virtual string EditCommand => this.GetResource(nameof(Strings.EditCommand)); + + /// + /// The resource string. + /// + public virtual string EditFavoritePrompt => this.GetResource(nameof(Strings.EditFavoritePrompt)); + + /// + /// The resource string. + /// + public virtual string EnterNewFavoriteLocationName => this.GetResource(nameof(Strings.EnterNewFavoriteLocationName)); + + /// + /// The resource string. + /// + public virtual string FavoriteAddedConfirmation => this.GetResource(nameof(Strings.FavoriteAddedConfirmation)); + + /// + /// The resource string. + /// + public virtual string FavoriteDeletedConfirmation => this.GetResource(nameof(Strings.FavoriteDeletedConfirmation)); + + /// + /// The resource string. + /// + public virtual string FavoriteEdittedConfirmation => this.GetResource(nameof(Strings.FavoriteEdittedConfirmation)); + + /// + /// The resource string. + /// + public virtual string FavoriteLocations => this.GetResource(nameof(Strings.FavoriteLocations)); + + /// + /// The resource string. + /// + public virtual string HelpCommand => this.GetResource(nameof(Strings.HelpCommand)); + + /// + /// The resource string. + /// + public virtual string HelpMessage => this.GetResource(nameof(Strings.HelpMessage)); + + /// + /// The resource string. + /// + public virtual string InvalidFavoriteLocationSelection => this.GetResource(nameof(Strings.InvalidFavoriteLocationSelection)); + + /// + /// The resource string. + /// + public virtual string InvalidFavoriteNameResponse => this.GetResource(nameof(Strings.InvalidFavoriteNameResponse)); + + + /// + /// The resource string. + /// + public virtual string InvalidLocationResponse => this.GetResource(nameof(Strings.InvalidLocationResponse)); + + /// + /// The resource string. + /// + public virtual string InvalidLocationResponseFacebook => this.GetResource(nameof(Strings.InvalidLocationResponseFacebook)); + + /// + /// The resource string. + /// + public virtual string InvalidStartBranchResponse => this.GetResource(nameof(Strings.InvalidStartBranchResponse)); + + /// + /// The resource string. + /// + public virtual string LocationNotFound => this.GetResource(nameof(Strings.LocationNotFound)); + + /// + /// The resource string. + /// + public virtual string Locality => this.GetResource(nameof(Strings.Locality)); + + /// + /// The resource string. + /// + public virtual string MultipleResultsFound => this.GetResource(nameof(Strings.MultipleResultsFound)); + + /// + /// The resource string. + /// + public virtual string NoFavoriteLocationsFound => this.GetResource(nameof(Strings.NoFavoriteLocationsFound)); + + /// + /// The resource string. + /// + public virtual string OtherComand => this.GetResource(nameof(Strings.OtherComand)); + + /// + /// The resource string. + /// + public virtual string OtherLocation => this.GetResource(nameof(Strings.OtherLocation)); + + /// + /// The resource string. + /// + public virtual string PostalCode => this.GetResource(nameof(Strings.PostalCode)); + + /// + /// The resource string. + /// + public virtual string Region => this.GetResource(nameof(Strings.Region)); + + /// + /// The resource string. + /// + public virtual string ResetCommand => this.GetResource(nameof(Strings.ResetCommand)); + + /// + /// The resource string. + /// + public virtual string ResetPrompt => this.GetResource(nameof(Strings.ResetPrompt)); + + /// + /// The resource string. + /// + public virtual string SelectFavoriteLocationPrompt => this.GetResource(nameof(Strings.SelectFavoriteLocationPrompt)); + + /// + /// The resource string. + /// + public virtual string SelectLocation => this.GetResource(nameof(Strings.SelectLocation)); + + /// + /// The resource string. + /// + public virtual string SingleResultFound => this.GetResource(nameof(Strings.SingleResultFound)); + + /// + /// The resource string. + /// + public virtual string StreetAddress => this.GetResource(nameof(Strings.StreetAddress)); + + /// + /// The resource string. + /// + public virtual string TitleSuffix => this.GetResource(nameof(Strings.TitleSuffix)); + + /// + /// The resource string. + /// + public virtual string TitleSuffixFacebook => this.GetResource(nameof(Strings.TitleSuffixFacebook)); /// /// Default constructor. Initializes strings using Microsoft.Bot.Builder.Location assembly resources. diff --git a/CSharp/BotBuilderLocation/Microsoft.Bot.Builder.Location.XML b/CSharp/BotBuilderLocation/Microsoft.Bot.Builder.Location.XML index 59d484c..cecb166 100644 --- a/CSharp/BotBuilderLocation/Microsoft.Bot.Builder.Location.XML +++ b/CSharp/BotBuilderLocation/Microsoft.Bot.Builder.Location.XML @@ -4,35 +4,79 @@ Microsoft.Bot.Builder.Location - + - A static class for creating location cards. + Runs when we expect the user to enter + + + + + + + + Represents different branch (sub dialog) types that can use to achieve its goal. - + - Creates locations hero cards (carousel). + The branch dialog type for retrieving a location. - The geo spatial API key. - List of the locations. - The locations card as attachments. - + - Creates location keyboard cards (buttons). + The branch dialog type for retrieving a location from a user's favorites. - The list of locations. - The card prompt. - The keyboard cards. - + + + The branch dialog type for saving a location to a user's favorites. + + + + + The branch dialog type for editing and retrieving one of the user's existing favorites. + + + + + Represents the interface that defines how location (sub)dialogs are created. + + + + + Given a branch parameter, creates the appropriate corresponding dialog that should run. + + The location dialog branch. + The location to be passed to the new dialog, if applicable. + The location name to be passed to the new dialog, if applicable. + The dialog. + + + + Resumes after a required fields dialog returns (in case of the rich and Facebook retrievers). + Resumes after a location retriever dialog returns or an edit favorite location dialog returns (in case of the favorite retriever). + + The context. + The result. + The asynchronous task. + + + + Tries to complete missing fields using Bing reverse geo-coder. + + The location. + The asynchronous task. + + Initializes a new instance of the class. - The Geo-Special Service - The geo spatial service API key. + The Geo-Special Service. + The card builder service. The prompt posted to the user when dialog starts. Indicates whether channel supports keyboard buttons or not. + The location options used to customize the experience. + The location required fields. The resource manager. @@ -182,33 +226,115 @@ Represents a dialog that prompts the user for any missing location fields. + + + Represents the interface that defines how the will + store and retrieve favorite locations for its users. + + + + + Gets whether the max allowed favorite location count has been reached. + + The bot data. + True if the maximum capacity has been reached, false otherwise. + + + + Checks whether the given location is one of the favorite locations of the + user inferred from the bot data. + + The bot data. + + + + + + Looks up the favorite locations value for the user inferred from the + bot data. + + The bot data. + >A list of favorite locations. + + + + Adds the given location to the favorites of the user inferred from the + bot data. + + The bot data. + The new favorite location value. + + + + Deletes the given location from the favorites of the user inferred from the + bot data. + + The bot data. + The favorite location value to be deleted. + + + + Updates the favorites of the user inferred from the bot data. + The favorite location whose value matched the given current value is updated + to the given new value. + + The bot data. + The updated favorite location value. + The updated favorite location value. + + + + A class for creating location cards. + + + + + Initializes a new instance of the class. + + The geo spatial API key. + + + + Creates locations hero cards. + + List of the locations. + Indicates whether a list containing exactly one location should have a '1.' prefix in its label. + List of strings that can be used as names or labels for the locations. + The locations card as a list. + + + + Creates a location keyboard card (buttons) with numbers and/or additional labels. + + The card prompt. + The number of options for which buttons should be made. + Additional buttons labels. + The keyboard card. + Represents the interface the defines how the will query for locations. - + Gets the locations asynchronously. - The geo spatial service API key. The address query. The found locations - + Gets the locations asynchronously. - The geo spatial service API key. The point latitude. The point longitude. The found locations - + Gets the map image URL. - The geo spatial service API key. The location. The pin point index. @@ -402,119 +528,24 @@ some or all the prompt strings. - - - The resource string. - - - - - The resource string. - - - - - The resource string. - - - - - The resource string. - - - - - The resource string. - - - - - The resource string. - - - - - The resource string. - - - - - The resource string. - - - - - The resource string. - - - - - The resource string. - - - - - The resource string. - - - - - The resource string. - - - - - The resource string. - - - - - The resource string. - - - - - The resource string. - - - - - The resource string. - - - - - The resource string. - - - - - The resource string. - - - - - The resource string. - - - - - The resource string. - - The resource string. - + - The resource string. + The resource string. - + - The resource string. + The resource string. + + + + + The resource string. @@ -527,9 +558,199 @@ The resource string. - + - The resource string. + The resource string. + + + + + The resource string. + + + + + The resource string. + + + + + The resource string. + + + + + The resource string. + + + + + The resource string. + + + + + The resource string. + + + + + The resource string. + + + + + The resource string. + + + + + The resource string. + + + + + The resource string. + + + + + The resource string. + + + + + The resource string. + + + + + The resource string. + + + + + The resource string. + + + + + The resource string. + + + + + The resource string. + + + + + The resource string. + + + + + The resource string. + + + + + The resource string. + + + + + The resource string. + + + + + The resource string. + + + + + The resource string. + + + + + The resource string. + + + + + The resource string. + + + + + The resource string. + + + + + The resource string. + + + + + The resource string. + + + + + The resource string. + + + + + The resource string. + + + + + The resource string. + + + + + The resource string. + + + + + The resource string. + + + + + The resource string. + + + + + The resource string. + + + + + The resource string. + + + + + The resource string. + + + + + The resource string. + + + + + The resource string. @@ -622,11 +843,23 @@ but still want the control to return to you a full address. - Due to the accuracy of reverse geo-coders, we only use it to capture + Due to the accuracy limitations of reverse geo-coders, we only use it to capture , , , and + + + Use this option if you do not want the LocationDialog to offer + keeping track of the user's favorite locations. + + + + + Use this option if you want the location dialog to skip the final + confirmation before returning the location + + Represents a dialog that handles retrieving a location from the user. @@ -736,16 +969,11 @@ The location required fields. The location resource manager. - + Initializes a new instance of the class. - The geo spatial API key. - The channel identifier. - The prompt posted to the user when dialog starts. - The geo spatial location service. - The location options used to customize the experience. - The location required fields. + The location dialog factory service. The location resource manager. @@ -757,19 +985,15 @@ - Resumes after native location dialog returns. + Resumes after: + 1- Any of the location retriever dialogs returns. + Or + 2- The add to favoritesDialog returns. The context. The result. The asynchronous task. - - - Tries to complete missing fields using Bing reverse geo-coder. - - The location. - The asynchronous task. - Creates the place object from location object. @@ -798,6 +1022,16 @@ Looks up a localized string similar to , . + + + Looks up a localized string similar to Do you want me to add this address to your favorite locations?. + + + + + Looks up a localized string similar to did not get that. Reply 'yes' if you want me to add this address to your favorite locations. Otherwise, reply 'no'.. + + Looks up a localized string similar to Please provide the {0}.. @@ -838,6 +1072,61 @@ Looks up a localized string similar to country. + + + Looks up a localized string similar to delete. + + + + + Looks up a localized string similar to OK, deletion aborted.. + + + + + Looks up a localized string similar to Are you sure you want to delete {0} from your favorite locations?. + + + + + Looks up a localized string similar to How would you like to pick a location?. + + + + + Looks up a localized string similar to edit. + + + + + Looks up a localized string similar to OK, let's edit {0}. Enter a new address.. + + + + + Looks up a localized string similar to OK, please enter a friendly name for this address. You can use 'home', 'work' or any other name you prefer.. + + + + + Looks up a localized string similar to OK, I added {0} to your favorite locations.. + + + + + Looks up a localized string similar to OK, I deleted {0} from your favorite locations.. + + + + + Looks up a localized string similar to OK, I editted {0} in your favorite locations with this new address.. + + + + + Looks up a localized string similar to Favorite Locations. + + Looks up a localized string similar to help. @@ -845,7 +1134,17 @@ - Looks up a localized string similar to The help message. + Looks up a localized string similar to Say or type a valid address when asked, and I will try to find it using Bing. You can provide the full address information (street no. / name, city, region, postal/zip code, country) or a part of it. If you want to change the address, say or type 'reset'. Finally, say or type 'cancel' to exit without providing an address.. + + + + + Looks up a localized string similar to Type or say a number to choose the address, enter 'other' to create a new favorite location, or enter 'cancel' to exit. You can also type or say 'edit' or 'delete' followed by a number to edit or delete the respective location.. + + + + + Looks up a localized string similar to Please enter a valid name for this address.. @@ -858,6 +1157,11 @@ Looks up a localized string similar to Tap on Send Location to proceed; type or say cancel to exit.. + + + Looks up a localized string similar to Tap one of the options to proceed; type or say cancel to exit.. + + Looks up a localized string similar to city or locality. @@ -873,11 +1177,21 @@ Looks up a localized string similar to I found these results. Type or say a number to choose the address, or enter 'other' to select another address.. + + + Looks up a localized string similar to You do not seem to have any favorite locations at the moment. Enter an address and you will be able to save it to your favorite locations.. + + Looks up a localized string similar to other. + + + Looks up a localized string similar to Other Location. + + Looks up a localized string similar to zip or postal code. @@ -898,6 +1212,11 @@ Looks up a localized string similar to OK, let's start over.. + + + Looks up a localized string similar to Here are your favorite locations. Type or say a number to use the respective location, or 'other' to use a different location. You can also type or say 'edit' or 'delete' followed by a number to edit or delete the respective location.. + + Looks up a localized string similar to Select a location. diff --git a/CSharp/BotBuilderLocation/Resources/Strings.Designer.cs b/CSharp/BotBuilderLocation/Resources/Strings.Designer.cs index 11e3f1c..c889f16 100644 --- a/CSharp/BotBuilderLocation/Resources/Strings.Designer.cs +++ b/CSharp/BotBuilderLocation/Resources/Strings.Designer.cs @@ -69,6 +69,24 @@ namespace Microsoft.Bot.Builder.Location.Resources { } } + /// + /// Looks up a localized string similar to Do you want me to add this address to your favorite locations?. + /// + internal static string AddToFavoritesAsk { + get { + return ResourceManager.GetString("AddToFavoritesAsk", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to did not get that. Reply 'yes' if you want me to add this address to your favorite locations. Otherwise, reply 'no'.. + /// + internal static string AddToFavoritesRetry { + get { + return ResourceManager.GetString("AddToFavoritesRetry", resourceCulture); + } + } + /// /// Looks up a localized string similar to Please provide the {0}.. /// @@ -141,6 +159,105 @@ namespace Microsoft.Bot.Builder.Location.Resources { } } + /// + /// Looks up a localized string similar to delete. + /// + internal static string DeleteCommand { + get { + return ResourceManager.GetString("DeleteCommand", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to OK, deletion aborted.. + /// + internal static string DeleteFavoriteAbortion { + get { + return ResourceManager.GetString("DeleteFavoriteAbortion", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Are you sure you want to delete {0} from your favorite locations?. + /// + internal static string DeleteFavoriteConfirmationAsk { + get { + return ResourceManager.GetString("DeleteFavoriteConfirmationAsk", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to How would you like to pick a location?. + /// + internal static string DialogStartBranchAsk { + get { + return ResourceManager.GetString("DialogStartBranchAsk", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to edit. + /// + internal static string EditCommand { + get { + return ResourceManager.GetString("EditCommand", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to OK, let's edit {0}. Enter a new address.. + /// + internal static string EditFavoritePrompt { + get { + return ResourceManager.GetString("EditFavoritePrompt", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to OK, please enter a friendly name for this address. You can use 'home', 'work' or any other name you prefer.. + /// + internal static string EnterNewFavoriteLocationName { + get { + return ResourceManager.GetString("EnterNewFavoriteLocationName", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to OK, I added {0} to your favorite locations.. + /// + internal static string FavoriteAddedConfirmation { + get { + return ResourceManager.GetString("FavoriteAddedConfirmation", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to OK, I deleted {0} from your favorite locations.. + /// + internal static string FavoriteDeletedConfirmation { + get { + return ResourceManager.GetString("FavoriteDeletedConfirmation", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to OK, I editted {0} in your favorite locations with this new address.. + /// + internal static string FavoriteEdittedConfirmation { + get { + return ResourceManager.GetString("FavoriteEdittedConfirmation", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Favorite Locations. + /// + internal static string FavoriteLocations { + get { + return ResourceManager.GetString("FavoriteLocations", resourceCulture); + } + } + /// /// Looks up a localized string similar to help. /// @@ -159,6 +276,24 @@ namespace Microsoft.Bot.Builder.Location.Resources { } } + /// + /// Looks up a localized string similar to Type or say a number to choose the address, enter 'other' to create a new favorite location, or enter 'cancel' to exit. You can also type or say 'edit' or 'delete' followed by a number to edit or delete the respective location.. + /// + internal static string InvalidFavoriteLocationSelection { + get { + return ResourceManager.GetString("InvalidFavoriteLocationSelection", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Please enter a valid name for this address.. + /// + internal static string InvalidFavoriteNameResponse { + get { + return ResourceManager.GetString("InvalidFavoriteNameResponse", resourceCulture); + } + } + /// /// Looks up a localized string similar to Type or say a number to choose the address, or enter 'cancel' to exit.. /// @@ -177,6 +312,15 @@ namespace Microsoft.Bot.Builder.Location.Resources { } } + /// + /// Looks up a localized string similar to Tap one of the options to proceed; type or say cancel to exit.. + /// + internal static string InvalidStartBranchResponse { + get { + return ResourceManager.GetString("InvalidStartBranchResponse", resourceCulture); + } + } + /// /// Looks up a localized string similar to city or locality. /// @@ -204,6 +348,15 @@ namespace Microsoft.Bot.Builder.Location.Resources { } } + /// + /// Looks up a localized string similar to You do not seem to have any favorite locations at the moment. Enter an address and you will be able to save it to your favorite locations.. + /// + internal static string NoFavoriteLocationsFound { + get { + return ResourceManager.GetString("NoFavoriteLocationsFound", resourceCulture); + } + } + /// /// Looks up a localized string similar to other. /// @@ -213,6 +366,15 @@ namespace Microsoft.Bot.Builder.Location.Resources { } } + /// + /// Looks up a localized string similar to Other Location. + /// + internal static string OtherLocation { + get { + return ResourceManager.GetString("OtherLocation", resourceCulture); + } + } + /// /// Looks up a localized string similar to zip or postal code. /// @@ -249,6 +411,15 @@ namespace Microsoft.Bot.Builder.Location.Resources { } } + /// + /// Looks up a localized string similar to Here are your favorite locations. Type or say a number to use the respective location, or 'other' to use a different location. You can also type or say 'edit' or 'delete' followed by a number to edit or delete the respective location.. + /// + internal static string SelectFavoriteLocationPrompt { + get { + return ResourceManager.GetString("SelectFavoriteLocationPrompt", resourceCulture); + } + } + /// /// Looks up a localized string similar to Select a location. /// diff --git a/CSharp/BotBuilderLocation/Resources/Strings.resx b/CSharp/BotBuilderLocation/Resources/Strings.resx index 0cc4648..f5a8643 100644 --- a/CSharp/BotBuilderLocation/Resources/Strings.resx +++ b/CSharp/BotBuilderLocation/Resources/Strings.resx @@ -120,6 +120,9 @@ , + + Do you want me to add this address to your favorite locations? + Please provide the {0}. @@ -144,6 +147,12 @@ country + + How would you like to pick a location? + + + Favorite Locations + help @@ -156,6 +165,9 @@ Tap on Send Location to proceed; type or say cancel to exit. + + Tap one of the options to proceed; type or say cancel to exit. + city or locality @@ -168,6 +180,9 @@ other + + Other Location + zip or postal code @@ -195,4 +210,46 @@ Tap 'Send Location' to choose an address. + + did not get that. Reply 'yes' if you want me to add this address to your favorite locations. Otherwise, reply 'no'. + + + OK, please enter a friendly name for this address. You can use 'home', 'work' or any other name you prefer. + + + OK, I added {0} to your favorite locations. + + + delete + + + edit + + + OK, let's edit {0}. Enter a new address. + + + OK, I deleted {0} from your favorite locations. + + + Type or say a number to choose the address, enter 'other' to create a new favorite location, or enter 'cancel' to exit. You can also type or say 'edit' or 'delete' followed by a number to edit or delete the respective location. + + + You do not seem to have any favorite locations at the moment. Enter an address and you will be able to save it to your favorite locations. + + + Here are your favorite locations. Type or say a number to use the respective location, or 'other' to use a different location. You can also type or say 'edit' or 'delete' followed by a number to edit or delete the respective location. + + + OK, deletion aborted. + + + Are you sure you want to delete {0} from your favorite locations? + + + OK, I editted {0} in your favorite locations with this new address. + + + Please enter a valid name for this address. + \ No newline at end of file diff --git a/Node/core/lib/botbuilder-location.js b/Node/core/lib/botbuilder-location.js index acbeb5e..3af0224 100644 --- a/Node/core/lib/botbuilder-location.js +++ b/Node/core/lib/botbuilder-location.js @@ -4,20 +4,24 @@ var botbuilder_1 = require("botbuilder"); var common = require("./common"); var consts_1 = require("./consts"); var place_1 = require("./place"); -var defaultLocationDialog = require("./dialogs/default-location-dialog"); -var facebookLocationDialog = require("./dialogs/facebook-location-dialog"); -var requiredFieldsDialog = require("./dialogs/required-fields-dialog"); -exports.LocationRequiredFields = requiredFieldsDialog.LocationRequiredFields; -exports.getFormattedAddressFromPlace = common.getFormattedAddressFromPlace; +var addFavoriteLocationDialog = require("./dialogs/add-favorite-location-dialog"); +var confirmDialog = require("./dialogs/confirm-dialog"); +var retrieveLocationDialog = require("./dialogs/retrieve-location-dialog"); +var requireFieldsDialog = require("./dialogs/require-fields-dialog"); +var retrieveFavoriteLocationDialog = require("./dialogs/retrieve-favorite-location-dialog"); +exports.LocationRequiredFields = requireFieldsDialog.LocationRequiredFields; +exports.getFormattedAddressFromLocation = common.getFormattedAddressFromLocation; exports.Place = place_1.Place; exports.createLibrary = function (apiKey) { if (typeof apiKey === "undefined") { throw "'apiKey' parameter missing"; } var lib = new botbuilder_1.Library(consts_1.LibraryName); - requiredFieldsDialog.register(lib); - defaultLocationDialog.register(lib, apiKey); - facebookLocationDialog.register(lib, apiKey); + retrieveFavoriteLocationDialog.register(lib, apiKey); + retrieveLocationDialog.register(lib, apiKey); + requireFieldsDialog.register(lib); + addFavoriteLocationDialog.register(lib); + confirmDialog.register(lib); lib.localePath(path.join(__dirname, 'locale/')); lib.dialog('locationPickerPrompt', getLocationPickerPrompt()); return lib; @@ -31,36 +35,33 @@ exports.getLocation = function (session, options) { }; function getLocationPickerPrompt() { return [ - function (session, args) { + function (session, args, next) { session.dialogData.args = args; - if (args.useNativeControl && session.message.address.channelId == 'facebook') { - session.beginDialog('facebook-location-dialog', args); + if (!args.skipFavorites) { + botbuilder_1.Prompts.choice(session, session.gettext(consts_1.Strings.DialogStartBranchAsk), [session.gettext(consts_1.Strings.FavoriteLocations), session.gettext(consts_1.Strings.OtherLocation)], { listStyle: botbuilder_1.ListStyle.button, retryPrompt: session.gettext(consts_1.Strings.InvalidStartBranchResponse) }); } else { - session.beginDialog('default-location-dialog', args); - } - }, - function (session, results, next) { - if (results.response && results.response.place) { - session.beginDialog('required-fields-dialog', { - place: results.response.place, - requiredFields: session.dialogData.args.requiredFields - }); - } - else { - next(results); + next(); + } + }, + function (session, results, next) { + if (results && results.response && results.response.entity === session.gettext(consts_1.Strings.FavoriteLocations)) { + session.beginDialog('retrieve-favorite-location-dialog', session.dialogData.args); + } + else { + session.beginDialog('retrieve-location-dialog', session.dialogData.args); } }, function (session, results, next) { if (results.response && results.response.place) { + session.dialogData.place = results.response.place; if (session.dialogData.args.skipConfirmationAsk) { - session.endDialogWithResult({ response: results.response.place }); + next({ response: { confirmed: true } }); } else { var separator = session.gettext(consts_1.Strings.AddressSeparator); - var promptText = session.gettext(consts_1.Strings.ConfirmationAsk, common.getFormattedAddressFromPlace(results.response.place, separator)); - session.dialogData.place = results.response.place; - botbuilder_1.Prompts.confirm(session, promptText, { listStyle: botbuilder_1.ListStyle.none }); + var promptText = session.gettext(consts_1.Strings.ConfirmationAsk, common.getFormattedAddressFromLocation(results.response.place, separator)); + session.beginDialog('confirm-dialog', { confirmationPrompt: promptText }); } } else { @@ -68,12 +69,21 @@ function getLocationPickerPrompt() { } }, function (session, results, next) { - if (!results.response || results.response.reset) { + session.dialogData.confirmed = results.response.confirmed; + if (results.response && results.response.confirmed && !session.dialogData.args.skipFavorites) { + session.beginDialog('add-favorite-location-dialog', { place: session.dialogData.place }); + } + else { + next(results); + } + }, + function (session, results, next) { + if (!session.dialogData.confirmed || (results.response && results.response.reset)) { session.send(consts_1.Strings.ResetPrompt); session.replaceDialog('locationPickerPrompt', session.dialogData.args); } else { - next({ response: session.dialogData.place }); + next({ response: common.processLocation(session.dialogData.place) }); } } ]; diff --git a/Node/core/lib/common.js b/Node/core/lib/common.js index 3d6854e..d9bded5 100644 --- a/Node/core/lib/common.js +++ b/Node/core/lib/common.js @@ -18,7 +18,7 @@ function createBaseDialog(options) { }); } exports.createBaseDialog = createBaseDialog; -function processLocation(location, includeStreetAddress) { +function processLocation(location) { var place = new place_1.Place(); place.type = location.entityType; place.name = location.name; @@ -28,43 +28,25 @@ function processLocation(location, includeStreetAddress) { place.locality = location.address.locality; place.postalCode = location.address.postalCode; place.region = location.address.adminDistrict; - if (includeStreetAddress) { - place.streetAddress = location.address.addressLine; - } + place.streetAddress = location.address.addressLine; } if (location.point && location.point.coordinates && location.point.coordinates.length == 2) { place.geo = new place_1.Geo(); - place.geo.latitude = location.point.coordinates[0]; - place.geo.longitude = location.point.coordinates[1]; + place.geo.latitude = location.point.coordinates[0].toString(); + place.geo.longitude = location.point.coordinates[1].toString(); } return place; } exports.processLocation = processLocation; -function buildPlaceFromGeo(latitude, longitude) { - var place = new place_1.Place(); - place.geo = new place_1.Geo(); - place.geo.latitude = latitude; - place.geo.longitude = longitude; - return place; -} -exports.buildPlaceFromGeo = buildPlaceFromGeo; -function getFormattedAddressFromPlace(place, separator) { +function getFormattedAddressFromLocation(location, separator) { var addressParts = new Array(); - if (place.streetAddress) { - addressParts.push(place.streetAddress); + if (location.address) { + addressParts = [location.address.addressLine, + location.address.locality, + location.address.adminDistrict, + location.address.postalCode, + location.address.countryRegion]; } - if (place.locality) { - addressParts.push(place.locality); - } - if (place.region) { - addressParts.push(place.region); - } - if (place.postalCode) { - addressParts.push(place.postalCode); - } - if (place.country) { - addressParts.push(place.country); - } - return addressParts.join(separator); + return addressParts.filter(function (i) { return i; }).join(separator); } -exports.getFormattedAddressFromPlace = getFormattedAddressFromPlace; +exports.getFormattedAddressFromLocation = getFormattedAddressFromLocation; diff --git a/Node/core/lib/consts.js b/Node/core/lib/consts.js index 06f97d2..cd6912b 100644 --- a/Node/core/lib/consts.js +++ b/Node/core/lib/consts.js @@ -2,6 +2,7 @@ exports.LibraryName = "botbuilder-location"; exports.Strings = { "AddressSeparator": "AddressSeparator", + "AddToFavoritesAsk": "AddToFavoritesAsk", "AskForEmptyAddressTemplate": "AskForEmptyAddressTemplate", "AskForPrefix": "AskForPrefix", "AskForTemplate": "AskForTemplate", @@ -9,15 +10,32 @@ exports.Strings = { "ConfirmationAsk": "ConfirmationAsk", "Country": "Country", "DefaultPrompt": "DefaultPrompt", + "DeleteCommand": "DeleteCommand", + "DeleteFavoriteAbortion": "DeleteFavoriteAbortion", + "DeleteFavoriteConfirmationAsk": "DeleteFavoriteConfirmationAsk", + "DialogStartBranchAsk": "DialogStartBranchAsk", + "EditCommand": "EditCommand", + "EditFavoritePrompt": "EditFavoritePrompt", + "EnterNewFavoriteLocationName": "EnterNewFavoriteLocationName", + "FavoriteAddedConfirmation": "FavoriteAddedConfirmation", + "FavoriteDeletedConfirmation": "FavoriteDeletedConfirmation", + "FavoriteEdittedConfirmation": "FavoriteEdittedConfirmation", + "FavoriteLocations": "FavoriteLocations", "HelpMessage": "HelpMessage", + "InvalidFavoriteLocationSelection": "InvalidFavoriteLocationSelection", "InvalidLocationResponse": "InvalidLocationResponse", "InvalidLocationResponseFacebook": "InvalidLocationResponseFacebook", + "InvalidStartBranchResponse": "InvalidStartBranchResponse", "LocationNotFound": "LocationNotFound", "Locality": "Locality", "MultipleResultsFound": "MultipleResultsFound", + "NoFavoriteLocationsFound": "NoFavoriteLocationsFound", + "OtherComand": "OtherComand", + "OtherLocation": "OtherLocation", "PostalCode": "PostalCode", "Region": "Region", "ResetPrompt": "ResetPrompt", + "SelectFavoriteLocationPrompt": "SelectFavoriteLocationPrompt", "SingleResultFound": "SingleResultFound", "StreetAddress": "StreetAddress", "TitleSuffixFacebook": "TitleSuffixFacebook", diff --git a/Node/core/lib/dialogs/add-favorite-location-dialog.js b/Node/core/lib/dialogs/add-favorite-location-dialog.js new file mode 100644 index 0000000..cd10e8e --- /dev/null +++ b/Node/core/lib/dialogs/add-favorite-location-dialog.js @@ -0,0 +1,48 @@ +"use strict"; +var common = require("../common"); +var consts_1 = require("../consts"); +var favorites_manager_1 = require("../services/favorites-manager"); +function register(library) { + library.dialog('add-favorite-location-dialog', createDialog()); + library.dialog('name-favorite-location-dialog', createNameFavoriteLocationDialog()); +} +exports.register = register; +function createDialog() { + return [ + function (session, args) { + var favoritesManager = new favorites_manager_1.FavoritesManager(session.userData); + if (favoritesManager.maxCapacityReached() || favoritesManager.isFavorite(args.place)) { + session.endDialogWithResult({ response: {} }); + } + else { + session.dialogData.place = args.place; + var addToFavoritesAsk = session.gettext(consts_1.Strings.AddToFavoritesAsk); + session.beginDialog('confirm-dialog', { confirmationPrompt: addToFavoritesAsk }); + } + }, + function (session, results, next) { + if (results.response && results.response.confirmed) { + session.beginDialog('name-favorite-location-dialog', { place: session.dialogData.place }); + } + else { + next(results); + } + } + ]; +} +function createNameFavoriteLocationDialog() { + return common.createBaseDialog() + .onBegin(function (session, args) { + session.dialogData.place = args.place; + session.send(session.gettext(consts_1.Strings.EnterNewFavoriteLocationName)).sendBatch(); + }).onDefault(function (session) { + var favoriteLocation = { + location: session.dialogData.place, + name: session.message.text + }; + var favoritesManager = new favorites_manager_1.FavoritesManager(session.userData); + favoritesManager.add(favoriteLocation); + session.send(session.gettext(consts_1.Strings.FavoriteAddedConfirmation, favoriteLocation.name)); + session.endDialogWithResult({ response: {} }); + }); +} diff --git a/Node/core/lib/dialogs/choice-dialog.js b/Node/core/lib/dialogs/choose-location-dialog.js similarity index 86% rename from Node/core/lib/dialogs/choice-dialog.js rename to Node/core/lib/dialogs/choose-location-dialog.js index 84a7ca1..997bc3e 100644 --- a/Node/core/lib/dialogs/choice-dialog.js +++ b/Node/core/lib/dialogs/choose-location-dialog.js @@ -3,7 +3,7 @@ var common = require("../common"); var consts_1 = require("../consts"); var place_1 = require("../place"); function register(library) { - library.dialog('choice-dialog', createDialog()); + library.dialog('choose-location-dialog', createDialog()); } exports.register = register; function createDialog() { @@ -21,8 +21,7 @@ function createDialog() { if (match) { var currentNumber = Number(match[0]); if (currentNumber > 0 && currentNumber <= session.dialogData.locations.length) { - var place = common.processLocation(session.dialogData.locations[currentNumber - 1], true); - session.endDialogWithResult({ response: { place: place } }); + session.endDialogWithResult({ response: { place: session.dialogData.locations[currentNumber - 1] } }); return; } } diff --git a/Node/core/lib/dialogs/confirm-dialog.js b/Node/core/lib/dialogs/confirm-dialog.js index 41bca95..db04e2a 100644 --- a/Node/core/lib/dialogs/confirm-dialog.js +++ b/Node/core/lib/dialogs/confirm-dialog.js @@ -8,19 +8,18 @@ exports.register = register; function createDialog() { return common.createBaseDialog() .onBegin(function (session, args) { - session.dialogData.locations = args.locations; - session.send(consts_1.Strings.SingleResultFound).sendBatch(); + var confirmationPrompt = args.confirmationPrompt; + session.send(confirmationPrompt).sendBatch(); }) .onDefault(function (session) { var message = parseBoolean(session.message.text); if (typeof message == 'boolean') { var result; if (message == true) { - var place = common.processLocation(session.dialogData.locations[0], true); - result = { response: { place: place } }; + result = { response: { confirmed: true } }; } else { - result = { response: { reset: true } }; + result = { response: { confirmed: false } }; } session.endDialogWithResult(result); return; diff --git a/Node/core/lib/dialogs/confirm-single-location-dialog.js b/Node/core/lib/dialogs/confirm-single-location-dialog.js new file mode 100644 index 0000000..acaa273 --- /dev/null +++ b/Node/core/lib/dialogs/confirm-single-location-dialog.js @@ -0,0 +1,22 @@ +"use strict"; +var consts_1 = require("../consts"); +function register(library) { + library.dialog('confirm-single-location-dialog', createDialog()); +} +exports.register = register; +function createDialog() { + return [ + function (session, args) { + session.dialogData.locations = args.locations; + session.beginDialog('confirm-dialog', { confirmationPrompt: session.gettext(consts_1.Strings.SingleResultFound) }); + }, + function (session, results, next) { + if (results.response && results.response.confirmed) { + session.endDialogWithResult({ response: { place: session.dialogData.locations[0] } }); + } + else { + session.endDialogWithResult({ response: { reset: true } }); + } + } + ]; +} diff --git a/Node/core/lib/dialogs/delete-favorite-location-dialog.js b/Node/core/lib/dialogs/delete-favorite-location-dialog.js new file mode 100644 index 0000000..150678c --- /dev/null +++ b/Node/core/lib/dialogs/delete-favorite-location-dialog.js @@ -0,0 +1,32 @@ +"use strict"; +var consts_1 = require("../consts"); +var favorites_manager_1 = require("../services/favorites-manager"); +function register(library, apiKey) { + library.dialog('delete-favorite-location-dialog', createDialog()); +} +exports.register = register; +function createDialog() { + return [ + function (session, args) { + session.dialogData.args = args; + session.dialogData.toBeDeleted = args.toBeDeleted; + var deleteFavoriteConfirmationAsk = session.gettext(consts_1.Strings.DeleteFavoriteConfirmationAsk, args.toBeDeleted.name); + session.beginDialog('confirm-dialog', { confirmationPrompt: deleteFavoriteConfirmationAsk }); + }, + function (session, results, next) { + if (results.response && results.response.confirmed) { + var favoritesManager = new favorites_manager_1.FavoritesManager(session.userData); + favoritesManager.delete(session.dialogData.toBeDeleted); + session.send(session.gettext(consts_1.Strings.FavoriteDeletedConfirmation, session.dialogData.toBeDeleted.name)); + session.replaceDialog('retrieve-favorite-location-dialog', session.dialogData.args); + } + else if (results.response && results.response.confirmed === false) { + session.send(session.gettext(consts_1.Strings.DeleteFavoriteAbortion)); + session.replaceDialog('retrieve-favorite-location-dialog', session.dialogData.args); + } + else { + next(results); + } + } + ]; +} diff --git a/Node/core/lib/dialogs/edit-fravorite-location-dialog.js b/Node/core/lib/dialogs/edit-fravorite-location-dialog.js new file mode 100644 index 0000000..3eff6ab --- /dev/null +++ b/Node/core/lib/dialogs/edit-fravorite-location-dialog.js @@ -0,0 +1,32 @@ +"use strict"; +var consts_1 = require("../consts"); +var favorites_manager_1 = require("../services/favorites-manager"); +function register(library, apiKey) { + library.dialog('edit-favorite-location-dialog', createDialog()); +} +exports.register = register; +function createDialog() { + return [ + function (session, args) { + session.dialogData.args = args; + session.dialogData.toBeEditted = args.toBeEditted; + session.send(session.gettext(consts_1.Strings.EditFavoritePrompt, args.toBeEditted.name)); + session.beginDialog('retrieve-location-dialog', session.dialogData.args); + }, + function (session, results, next) { + if (results.response && results.response.place) { + var favoritesManager = new favorites_manager_1.FavoritesManager(session.userData); + var newfavoriteLocation = { + location: results.response.place, + name: session.dialogData.toBeEditted.name + }; + favoritesManager.update(session.dialogData.toBeEditted, newfavoriteLocation); + session.send(session.gettext(consts_1.Strings.FavoriteEdittedConfirmation, session.dialogData.toBeEditted.name)); + session.endDialogWithResult({ response: { place: results.response.place } }); + } + else { + next(results); + } + } + ]; +} diff --git a/Node/core/lib/dialogs/required-fields-dialog.js b/Node/core/lib/dialogs/require-fields-dialog.js similarity index 84% rename from Node/core/lib/dialogs/required-fields-dialog.js rename to Node/core/lib/dialogs/require-fields-dialog.js index bd264f9..e4f86de 100644 --- a/Node/core/lib/dialogs/required-fields-dialog.js +++ b/Node/core/lib/dialogs/require-fields-dialog.js @@ -12,15 +12,15 @@ var LocationRequiredFields; LocationRequiredFields[LocationRequiredFields["country"] = 16] = "country"; })(LocationRequiredFields = exports.LocationRequiredFields || (exports.LocationRequiredFields = {})); function register(library) { - library.dialog('required-fields-dialog', createDialog()); + library.dialog('require-fields-dialog', createDialog()); } exports.register = register; var fields = [ - { name: "streetAddress", prompt: consts_1.Strings.StreetAddress, flag: LocationRequiredFields.streetAddress }, + { name: "addressLine", prompt: consts_1.Strings.StreetAddress, flag: LocationRequiredFields.streetAddress }, { name: "locality", prompt: consts_1.Strings.Locality, flag: LocationRequiredFields.locality }, - { name: "region", prompt: consts_1.Strings.Region, flag: LocationRequiredFields.region }, + { name: "adminDistrict", prompt: consts_1.Strings.Region, flag: LocationRequiredFields.region }, { name: "postalCode", prompt: consts_1.Strings.PostalCode, flag: LocationRequiredFields.postalCode }, - { name: "country", prompt: consts_1.Strings.Country, flag: LocationRequiredFields.country }, + { name: "countryRegion", prompt: consts_1.Strings.Country, flag: LocationRequiredFields.country }, ]; function createDialog() { return common.createBaseDialog({ recognizeMode: botbuilder_1.RecognizeMode.onBegin }) @@ -42,7 +42,7 @@ function createDialog() { return; } session.dialogData.lastInput = session.message.text; - session.dialogData.place[fields[index].name] = session.message.text; + session.dialogData.place.address[fields[index].name] = session.message.text; } index++; while (index < fields.length) { @@ -61,11 +61,11 @@ function createDialog() { }); } function completeFieldIfMissing(session, field) { - if ((field.flag & session.dialogData.requiredFieldsFlag) && !session.dialogData.place[field.name]) { + if ((field.flag & session.dialogData.requiredFieldsFlag) && !session.dialogData.place.address[field.name]) { var prefix = ""; var prompt = ""; if (typeof session.dialogData.lastInput === "undefined") { - var formattedAddress = common.getFormattedAddressFromPlace(session.dialogData.place, session.gettext(consts_1.Strings.AddressSeparator)); + var formattedAddress = common.getFormattedAddressFromLocation(session.dialogData.place, session.gettext(consts_1.Strings.AddressSeparator)); if (formattedAddress) { prefix = session.gettext(consts_1.Strings.AskForPrefix, formattedAddress); prompt = session.gettext(consts_1.Strings.AskForTemplate, session.gettext(field.prompt)); diff --git a/Node/core/lib/dialogs/default-location-dialog.js b/Node/core/lib/dialogs/resolve-bing-location-dialog.js similarity index 60% rename from Node/core/lib/dialogs/default-location-dialog.js rename to Node/core/lib/dialogs/resolve-bing-location-dialog.js index 76f79d4..fb7965e 100644 --- a/Node/core/lib/dialogs/default-location-dialog.js +++ b/Node/core/lib/dialogs/resolve-bing-location-dialog.js @@ -1,15 +1,14 @@ "use strict"; var common = require("../common"); var consts_1 = require("../consts"); -var botbuilder_1 = require("botbuilder"); -var map_card_1 = require("../map-card"); var locationService = require("../services/bing-geospatial-service"); -var confirmDialog = require("./confirm-dialog"); -var choiceDialog = require("./choice-dialog"); +var confirmSingleLocationDialog = require("./confirm-single-location-dialog"); +var chooseLocationDialog = require("./choose-location-dialog"); +var location_card_builder_1 = require("../services/location-card-builder"); function register(library, apiKey) { - confirmDialog.register(library); - choiceDialog.register(library); - library.dialog('default-location-dialog', createDialog()); + confirmSingleLocationDialog.register(library); + chooseLocationDialog.register(library); + library.dialog('resolve-bing-location-dialog', createDialog()); library.dialog('location-resolve-dialog', createLocationResolveDialog(apiKey)); } exports.register = register; @@ -23,10 +22,10 @@ function createDialog() { if (results.response && results.response.locations) { var locations = results.response.locations; if (locations.length == 1) { - session.beginDialog('confirm-dialog', { locations: locations }); + session.beginDialog('confirm-single-location-dialog', { locations: locations }); } else { - session.beginDialog('choice-dialog', { locations: locations }); + session.beginDialog('choose-location-dialog', { locations: locations }); } } else { @@ -50,30 +49,10 @@ function createLocationResolveDialog(apiKey) { } var locationCount = Math.min(MAX_CARD_COUNT, locations.length); locations = locations.slice(0, locationCount); - var reply = createLocationsCard(apiKey, session, locations); + var reply = new location_card_builder_1.LocationCardBuilder(apiKey).createHeroCards(session, locations); session.send(reply); session.endDialogWithResult({ response: { locations: locations } }); }) .catch(function (error) { return session.error(error); }); }); } -function createLocationsCard(apiKey, session, locations) { - var cards = new Array(); - for (var i = 0; i < locations.length; i++) { - cards.push(constructCard(apiKey, session, locations, i)); - } - return new botbuilder_1.Message(session) - .attachmentLayout(botbuilder_1.AttachmentLayout.carousel) - .attachments(cards); -} -function constructCard(apiKey, session, locations, index) { - var location = locations[index]; - var card = new map_card_1.MapCard(apiKey, session); - if (locations.length > 1) { - card.location(location, index + 1); - } - else { - card.location(location); - } - return card; -} diff --git a/Node/core/lib/dialogs/facebook-location-dialog.js b/Node/core/lib/dialogs/retrieve-facebook-location-dialog.js similarity index 85% rename from Node/core/lib/dialogs/facebook-location-dialog.js rename to Node/core/lib/dialogs/retrieve-facebook-location-dialog.js index 26db8be..66e23a4 100644 --- a/Node/core/lib/dialogs/facebook-location-dialog.js +++ b/Node/core/lib/dialogs/retrieve-facebook-location-dialog.js @@ -4,7 +4,7 @@ var common = require("../common"); var botbuilder_1 = require("botbuilder"); var locationService = require("../services/bing-geospatial-service"); function register(library, apiKey) { - library.dialog('facebook-location-dialog', createDialog(apiKey)); + library.dialog('retrive-facebook-location-dialog', createDialog(apiKey)); library.dialog('facebook-location-resolve-dialog', createLocationResolveDialog()); } exports.register = register; @@ -16,11 +16,11 @@ function createDialog(apiKey) { }, function (session, results, next) { if (session.dialogData.args.reverseGeocode && results.response && results.response.place) { - locationService.getLocationByPoint(apiKey, results.response.place.geo.latitude, results.response.place.geo.longitude) + locationService.getLocationByPoint(apiKey, results.response.place.point.coordinates[0], results.response.place.point.coordinates[1]) .then(function (locations) { var place; if (locations.length) { - place = common.processLocation(locations[0], false); + place = locations[0]; } else { place = results.response.place; @@ -46,7 +46,7 @@ function createLocationResolveDialog() { var entities = session.message.entities; for (var i = 0; i < entities.length; i++) { if (entities[i].type == "Place" && entities[i].geo && entities[i].geo.latitude && entities[i].geo.longitude) { - session.endDialogWithResult({ response: { place: common.buildPlaceFromGeo(entities[i].geo.latitude, entities[i].geo.longitude) } }); + session.endDialogWithResult({ response: { place: buildLocationFromGeo(entities[i].geo.latitude, entities[i].geo.longitude) } }); return; } } @@ -66,3 +66,7 @@ function sendLocationPrompt(session, prompt) { }); return session.send(message); } +function buildLocationFromGeo(latitude, longitude) { + var coordinates = [latitude, longitude]; + return { point: { coordinates: coordinates } }; +} diff --git a/Node/core/lib/dialogs/retrieve-favorite-location-dialog.js b/Node/core/lib/dialogs/retrieve-favorite-location-dialog.js new file mode 100644 index 0000000..c1326a3 --- /dev/null +++ b/Node/core/lib/dialogs/retrieve-favorite-location-dialog.js @@ -0,0 +1,87 @@ +"use strict"; +var common = require("../common"); +var consts_1 = require("../consts"); +var location_card_builder_1 = require("../services/location-card-builder"); +var favorites_manager_1 = require("../services/favorites-manager"); +var deleteFavoriteLocationDialog = require("./delete-favorite-location-dialog"); +var editFavoriteLocationDialog = require("./edit-fravorite-location-dialog"); +function register(library, apiKey) { + library.dialog('retrieve-favorite-location-dialog', createDialog(apiKey)); + deleteFavoriteLocationDialog.register(library, apiKey); + editFavoriteLocationDialog.register(library, apiKey); +} +exports.register = register; +function createDialog(apiKey) { + return common.createBaseDialog() + .onBegin(function (session, args) { + session.dialogData.args = args; + var favoritesManager = new favorites_manager_1.FavoritesManager(session.userData); + var userFavorites = favoritesManager.getFavorites(); + if (userFavorites.length == 0) { + session.send(session.gettext(consts_1.Strings.NoFavoriteLocationsFound)); + session.replaceDialog('retrieve-location-dialog', session.dialogData.args); + return; + } + session.dialogData.userFavorites = userFavorites; + var locations = []; + var names = []; + for (var i = 0; i < userFavorites.length; i++) { + locations.push(userFavorites[i].location); + names.push(userFavorites[i].name); + } + session.send(new location_card_builder_1.LocationCardBuilder(apiKey).createHeroCards(session, locations, true, names)); + session.send(session.gettext(consts_1.Strings.SelectFavoriteLocationPrompt)).sendBatch(); + }).onDefault(function (session) { + var text = session.message.text; + if (text === session.gettext(consts_1.Strings.OtherComand)) { + session.replaceDialog('retrieve-location-dialog', session.dialogData.args); + } + else { + var selection = tryParseCommandSelection(text, session.dialogData.userFavorites.length); + if (selection.command === "select") { + session.replaceDialog('require-fields-dialog', { + place: session.dialogData.userFavorites[selection.index - 1].location, + requiredFields: session.dialogData.args.requiredFields + }); + } + else if (selection.command === session.gettext(consts_1.Strings.DeleteCommand)) { + session.dialogData.args.toBeDeleted = session.dialogData.userFavorites[selection.index - 1]; + session.replaceDialog('delete-favorite-location-dialog', session.dialogData.args); + } + else if (selection.command === session.gettext(consts_1.Strings.EditCommand)) { + session.dialogData.args.toBeEditted = session.dialogData.userFavorites[selection.index - 1]; + session.replaceDialog('edit-favorite-location-dialog', session.dialogData.args); + } + else { + session.send(session.gettext(consts_1.Strings.InvalidFavoriteLocationSelection)).sendBatch(); + } + } + }); +} +function tryParseNumberSelection(text) { + var tokens = text.trim().split(' '); + if (tokens.length == 1) { + var numberExp = /[+-]?(?:\d+\.?\d*|\d*\.?\d+)/; + var match = numberExp.exec(text); + if (match) { + return Number(match[0]); + } + } + return -1; +} +function tryParseCommandSelection(text, maxIndex) { + var tokens = text.trim().split(' '); + if (tokens.length == 1) { + var index = tryParseNumberSelection(text); + if (index > 0 && index <= maxIndex) { + return { index: index, command: "select" }; + } + } + else if (tokens.length == 2) { + var index = tryParseNumberSelection(tokens[1]); + if (index > 0 && index <= maxIndex) { + return { index: index, command: tokens[0] }; + } + } + return { command: "" }; +} diff --git a/Node/core/lib/dialogs/retrieve-location-dialog.js b/Node/core/lib/dialogs/retrieve-location-dialog.js new file mode 100644 index 0000000..ddda242 --- /dev/null +++ b/Node/core/lib/dialogs/retrieve-location-dialog.js @@ -0,0 +1,33 @@ +"use strict"; +var resolveBingLocationDialog = require("./resolve-bing-location-dialog"); +var retrieveFacebookLocationDialog = require("./retrieve-facebook-location-dialog"); +function register(library, apiKey) { + library.dialog('retrieve-location-dialog', createDialog()); + resolveBingLocationDialog.register(library, apiKey); + retrieveFacebookLocationDialog.register(library, apiKey); +} +exports.register = register; +function createDialog() { + return [ + function (session, args) { + session.dialogData.args = args; + if (args.useNativeControl && session.message.address.channelId == 'facebook') { + session.beginDialog('retrieve-facebook-location-dialog', args); + } + else { + session.beginDialog('resolve-bing-location-dialog', args); + } + }, + function (session, results, next) { + if (results.response && results.response.place) { + session.beginDialog('require-fields-dialog', { + place: results.response.place, + requiredFields: session.dialogData.args.requiredFields + }); + } + else { + next(results); + } + } + ]; +} diff --git a/Node/core/lib/favorite-location.js b/Node/core/lib/favorite-location.js new file mode 100644 index 0000000..fd6a6e3 --- /dev/null +++ b/Node/core/lib/favorite-location.js @@ -0,0 +1,7 @@ +"use strict"; +var FavoriteLocation = (function () { + function FavoriteLocation() { + } + return FavoriteLocation; +}()); +exports.FavoriteLocation = FavoriteLocation; diff --git a/Node/core/lib/locale/en/botbuilder-location.json b/Node/core/lib/locale/en/botbuilder-location.json index 042bf8e..e38b310 100644 --- a/Node/core/lib/locale/en/botbuilder-location.json +++ b/Node/core/lib/locale/en/botbuilder-location.json @@ -1,5 +1,6 @@ { "AddressSeparator": ", ", + "AddToFavoritesAsk": "Do you want me to add this address to your favorite locations?", "AskForEmptyAddressTemplate": "Please provide the %s.", "AskForPrefix": "OK %s.", "AskForTemplate": " Please also provide the %s.", @@ -7,15 +8,32 @@ "ConfirmationAsk": "OK, I will ship to %s. Is that correct? Enter 'yes' or 'no'.", "Country": "country", "DefaultPrompt": "Where should I ship your order?", + "DeleteCommand": "delete", + "DeleteFavoriteAbortion": "OK, deletion aborted.", + "DeleteFavoriteConfirmationAsk": "Are you sure you want to delete %s from your favorite locations?", + "DialogStartBranchAsk": "How would you like to pick a location?", + "EditCommand": "edit", + "EditFavoritePrompt": "OK, let's edit %s. Enter a new address.", + "EnterNewFavoriteLocationName": "OK, please enter a friendly name for this address. You can use 'home', 'work' or any other name you prefer.", + "FavoriteAddedConfirmation": "OK, I added %s to your favorite locations.", + "FavoriteDeletedConfirmation": "OK, I deleted %s from your favorite locations.", + "FavoriteEdittedConfirmation": "OK, I editted %s in your favorite locations with this new address.", + "FavoriteLocations": "Favorite Locations", "HelpMessage": "Say or type a valid address when asked, and I will try to find it using Bing. You can provide the full address information (street no. / name, city, region, postal/zip code, country) or a part of it. If you want to change the address, say or type 'reset'. Finally, say or type 'cancel' to exit without providing an address.", + "InvalidFavoriteLocationSelection": "Type or say a number to choose the address, enter 'other' to create a new favorite location, or enter 'cancel' to exit. You can also type or say 'edit' or 'delete' followed by a number to edit or delete the respective location.", "InvalidLocationResponse": "Didn't get that. Choose a location or cancel.", "InvalidLocationResponseFacebook": "Tap on Send Location to proceed; type or say cancel to exit.", + "InvalidStartBranchResponse": "Tap one of the options to proceed; type or say cancel to exit.", "LocationNotFound": "I could not find this address. Please try again.", "Locality": "city or locality", "MultipleResultsFound": "I found these results. Type or say a number to choose the address, or enter 'other' to select another address.", + "NoFavoriteLocationsFound": "You do not seem to have any favorite locations at the moment. Enter an address and you will be able to save it to your favorite locations.", + "OtherComand": "other", + "OtherLocation": "Other Location", "PostalCode": "zip or postal code", "Region": "state or region", "ResetPrompt": "OK, let's start over.", + "SelectFavoriteLocationPrompt": "Here are your favorite locations. Type or say a number to use the respective location, or 'other' to use a different location. You can also type or say 'edit' or 'delete' followed by a number to edit or delete the respective location.", "SingleResultFound": "I found this result. Is this the correct address?", "StreetAddress": "street address", "TitleSuffix": " Type or say an address", diff --git a/Node/core/lib/map-card.js b/Node/core/lib/map-card.js index 5568dc4..04f3829 100644 --- a/Node/core/lib/map-card.js +++ b/Node/core/lib/map-card.js @@ -13,12 +13,15 @@ var MapCard = (function (_super) { _this.apiKey = apiKey; return _this; } - MapCard.prototype.location = function (location, index) { - var indexText = ""; + MapCard.prototype.location = function (location, index, locationName) { + var prefixText = ""; if (index !== undefined) { - indexText = index + ". "; + prefixText = index + ". "; } - this.subtitle(indexText + location.address.formattedAddress); + if (locationName !== undefined) { + prefixText += locationName + ": "; + } + this.subtitle(prefixText + location.address.formattedAddress); if (location.point) { var locationUrl; try { diff --git a/Node/core/lib/rawLocation.js b/Node/core/lib/rawLocation.js new file mode 100644 index 0000000..5bd4846 --- /dev/null +++ b/Node/core/lib/rawLocation.js @@ -0,0 +1,17 @@ +"use strict"; +var RawLocation = (function () { + function RawLocation() { + } + return RawLocation; +}()); +exports.RawLocation = RawLocation; +var Address = (function () { + function Address() { + } + return Address; +}()); +var Point = (function () { + function Point() { + } + return Point; +}()); diff --git a/Node/core/lib/services/favorites-manager.js b/Node/core/lib/services/favorites-manager.js new file mode 100644 index 0000000..336290b --- /dev/null +++ b/Node/core/lib/services/favorites-manager.js @@ -0,0 +1,65 @@ +"use strict"; +var FavoritesManager = (function () { + function FavoritesManager(userData) { + this.userData = userData; + this.maxFavoriteCount = 5; + this.favoritesKey = 'favorites'; + } + FavoritesManager.prototype.maxCapacityReached = function () { + return this.getFavorites().length >= this.maxFavoriteCount; + }; + FavoritesManager.prototype.isFavorite = function (location) { + var favorites = this.getFavorites(); + for (var i = 0; i < favorites.length; i++) { + if (this.areEqual(favorites[i].location, location)) { + return true; + } + } + return false; + }; + FavoritesManager.prototype.add = function (favoriteLocation) { + var favorites = this.getFavorites(); + if (favorites.length >= this.maxFavoriteCount) { + throw ('The max allowed number of favorite locations has already been reached.'); + } + favorites.push(favoriteLocation); + this.userData[this.favoritesKey] = favorites; + }; + FavoritesManager.prototype.delete = function (favoriteLocation) { + var favorites = this.getFavorites(); + var newFavorites = []; + for (var i = 0; i < favorites.length; i++) { + if (!this.areEqual(favorites[i].location, favoriteLocation.location)) { + newFavorites.push(favorites[i]); + } + } + this.userData[this.favoritesKey] = newFavorites; + }; + FavoritesManager.prototype.update = function (currentValue, newValue) { + var favorites = this.getFavorites(); + var newFavorites = []; + for (var i = 0; i < favorites.length; i++) { + if (this.areEqual(favorites[i].location, currentValue.location)) { + newFavorites.push(newValue); + } + else { + newFavorites.push(favorites[i]); + } + } + this.userData[this.favoritesKey] = newFavorites; + }; + FavoritesManager.prototype.getFavorites = function () { + var storedFavorites = this.userData[this.favoritesKey]; + if (storedFavorites) { + return storedFavorites; + } + else { + return []; + } + }; + FavoritesManager.prototype.areEqual = function (location0, location1) { + return location0.address.formattedAddress === location1.address.formattedAddress; + }; + return FavoritesManager; +}()); +exports.FavoritesManager = FavoritesManager; diff --git a/Node/core/lib/services/location-card-builder.js b/Node/core/lib/services/location-card-builder.js new file mode 100644 index 0000000..1659fbd --- /dev/null +++ b/Node/core/lib/services/location-card-builder.js @@ -0,0 +1,35 @@ +"use strict"; +var botbuilder_1 = require("botbuilder"); +var map_card_1 = require("../map-card"); +var LocationCardBuilder = (function () { + function LocationCardBuilder(apiKey) { + this.apiKey = apiKey; + } + LocationCardBuilder.prototype.createHeroCards = function (session, locations, alwaysShowNumericPrefix, locationNames) { + var cards = new Array(); + for (var i = 0; i < locations.length; i++) { + cards.push(this.constructCard(session, locations, i, alwaysShowNumericPrefix, locationNames)); + } + return new botbuilder_1.Message(session) + .attachmentLayout(botbuilder_1.AttachmentLayout.carousel) + .attachments(cards); + }; + LocationCardBuilder.prototype.constructCard = function (session, locations, index, alwaysShowNumericPrefix, locationNames) { + var location = locations[index]; + var card = new map_card_1.MapCard(this.apiKey, session); + if (alwaysShowNumericPrefix || locations.length > 1) { + if (locationNames) { + card.location(location, index + 1, locationNames[index]); + } + else { + card.location(location, index + 1); + } + } + else { + card.location(location); + } + return card; + }; + return LocationCardBuilder; +}()); +exports.LocationCardBuilder = LocationCardBuilder; diff --git a/Node/core/src/botbuilder-location.d.ts b/Node/core/src/botbuilder-location.d.ts index 7b5b355..3c35db7 100644 --- a/Node/core/src/botbuilder-location.d.ts +++ b/Node/core/src/botbuilder-location.d.ts @@ -1,4 +1,5 @@ import * as builder from "botbuilder"; +import { RawLocation } from "./rawLocation"; //============================================================================= // @@ -34,7 +35,12 @@ export interface ILocationPromptOptions { /** * Boolean to indicate if the control will try to reverse geocode the lat/long coordinates returned by FB Messenger's location picker GUI dialog. It does not have any effect on other messaging channels. */ - reverseGeocode?: boolean + reverseGeocode?: boolean, + + /** + * Use this option if you do not want the control to offer keeping track of the user's favorite locations. + */ + skipFavorites?: boolean } //============================================================================= @@ -125,7 +131,7 @@ export function getLocation(session: builder.Session, options: ILocationPromptOp /** * Gets a formatted address string. - * @param place Place object containing the address. + * @param location object containing the address. * @param separator The string separating the address parts. */ -export function getFormattedAddressFromPlace(place: Place, separator: string): string; \ No newline at end of file +export function getFormattedAddressFromLocation(location: RawLocation, separator: string): string; diff --git a/Node/core/src/botbuilder-location.ts b/Node/core/src/botbuilder-location.ts index 70eda4a..717e8fa 100644 --- a/Node/core/src/botbuilder-location.ts +++ b/Node/core/src/botbuilder-location.ts @@ -1,22 +1,25 @@ import * as path from 'path'; -import { Library, Session, IDialogResult, Prompts, ListStyle } from 'botbuilder'; +import { IDialogResult, IPromptOptions, Library, ListStyle, Prompts, Session} from 'botbuilder'; import * as common from './common'; import { Strings, LibraryName } from './consts'; import { Place } from './place'; -import * as defaultLocationDialog from './dialogs/default-location-dialog'; -import * as facebookLocationDialog from './dialogs/facebook-location-dialog' -import * as requiredFieldsDialog from './dialogs/required-fields-dialog'; +import * as addFavoriteLocationDialog from './dialogs/add-favorite-location-dialog'; +import * as confirmDialog from './dialogs/confirm-dialog'; +import * as retrieveLocationDialog from './dialogs/retrieve-location-dialog' +import * as requireFieldsDialog from './dialogs/require-fields-dialog'; +import * as retrieveFavoriteLocationDialog from './dialogs/retrieve-favorite-location-dialog' export interface ILocationPromptOptions { prompt: string; - requiredFields?: requiredFieldsDialog.LocationRequiredFields; + requiredFields?: requireFieldsDialog.LocationRequiredFields; skipConfirmationAsk?: boolean; useNativeControl?: boolean, - reverseGeocode?: boolean + reverseGeocode?: boolean, + skipFavorites?: boolean } -exports.LocationRequiredFields = requiredFieldsDialog.LocationRequiredFields; -exports.getFormattedAddressFromPlace = common.getFormattedAddressFromPlace; +exports.LocationRequiredFields = requireFieldsDialog.LocationRequiredFields; +exports.getFormattedAddressFromLocation = common.getFormattedAddressFromLocation; exports.Place = Place; //========================================================= @@ -30,10 +33,11 @@ exports.createLibrary = (apiKey: string): Library => { } var lib = new Library(LibraryName); - - requiredFieldsDialog.register(lib); - defaultLocationDialog.register(lib, apiKey); - facebookLocationDialog.register(lib, apiKey); + retrieveFavoriteLocationDialog.register(lib, apiKey); + retrieveLocationDialog.register(lib, apiKey); + requireFieldsDialog.register(lib); + addFavoriteLocationDialog.register(lib); + confirmDialog.register(lib); lib.localePath(path.join(__dirname, 'locale/')); lib.dialog('locationPickerPrompt', getLocationPickerPrompt()); @@ -56,47 +60,63 @@ exports.getLocation = function (session: Session, options: ILocationPromptOption function getLocationPickerPrompt() { return [ - (session: Session, args: ILocationPromptOptions) => { + // handle different ways of retrieving a location (favorite, other, etc) + (session: Session, args: ILocationPromptOptions, next: (results?: IDialogResult) => void) => { session.dialogData.args = args; - if (args.useNativeControl && session.message.address.channelId == 'facebook') { - session.beginDialog('facebook-location-dialog', args); + if (!args.skipFavorites) { + Prompts.choice( + session, + session.gettext(Strings.DialogStartBranchAsk), + [ session.gettext(Strings.FavoriteLocations), session.gettext(Strings.OtherLocation) ], + { listStyle: ListStyle.button, retryPrompt: session.gettext(Strings.InvalidStartBranchResponse)}); } else { - session.beginDialog('default-location-dialog', args); + next(); } }, + // retrieve location (session: Session, results: IDialogResult, next: (results?: IDialogResult) => void) => { - if (results.response && results.response.place) { - session.beginDialog('required-fields-dialog', { - place: results.response.place, - requiredFields: session.dialogData.args.requiredFields - }) - } else { - next(results); + if (results && results.response && results.response.entity === session.gettext(Strings.FavoriteLocations)) { + session.beginDialog('retrieve-favorite-location-dialog', session.dialogData.args); + } + else { + session.beginDialog('retrieve-location-dialog', session.dialogData.args); } }, + // make final confirmation (session: Session, results: IDialogResult, next: (results?: IDialogResult) => void) => { if (results.response && results.response.place) { + session.dialogData.place = results.response.place; if (session.dialogData.args.skipConfirmationAsk) { - session.endDialogWithResult({ response: results.response.place }); + next({ response: { confirmed: true }}); } else { var separator = session.gettext(Strings.AddressSeparator); - var promptText = session.gettext(Strings.ConfirmationAsk, common.getFormattedAddressFromPlace(results.response.place, separator)); - session.dialogData.place = results.response.place; - Prompts.confirm(session, promptText, { listStyle: ListStyle.none }) + var promptText = session.gettext(Strings.ConfirmationAsk, common.getFormattedAddressFromLocation(results.response.place, separator)); + session.beginDialog('confirm-dialog' , { confirmationPrompt: promptText }); } - } else { + } + else { next(results); } }, + // offer add to favorites, if applicable (session: Session, results: IDialogResult, next: (results?: IDialogResult) => void) => { - if (!results.response || results.response.reset) { - session.send(Strings.ResetPrompt) + session.dialogData.confirmed = results.response.confirmed; + if(results.response && results.response.confirmed && !session.dialogData.args.skipFavorites) { + session.beginDialog('add-favorite-location-dialog', { place : session.dialogData.place }); + } + else { + next(results); + } + }, + (session: Session, results: IDialogResult, next: (results?: IDialogResult) => void) => { + if ( !session.dialogData.confirmed || (results.response && results.response.reset)) { + session.send(Strings.ResetPrompt); session.replaceDialog('locationPickerPrompt', session.dialogData.args); } else { - next({ response: session.dialogData.place }); + next({ response: common.processLocation(session.dialogData.place) }); } } ]; diff --git a/Node/core/src/common.ts b/Node/core/src/common.ts index ba4822a..3809305 100644 --- a/Node/core/src/common.ts +++ b/Node/core/src/common.ts @@ -1,6 +1,7 @@ import { Session, IntentDialog } from 'botbuilder'; import { Strings } from './consts'; import { Place, Geo } from './place'; +import { RawLocation } from './rawLocation' export function createBaseDialog(options?: any): IntentDialog { return new IntentDialog(options) @@ -18,7 +19,7 @@ export function createBaseDialog(options?: any): IntentDialog { }); } -export function processLocation(location: any, includeStreetAddress: boolean): Place { +export function processLocation(location: RawLocation): Place { var place: Place = new Place(); place.type = location.entityType; place.name = location.name; @@ -29,51 +30,28 @@ export function processLocation(location: any, includeStreetAddress: boolean): P place.locality = location.address.locality; place.postalCode = location.address.postalCode; place.region = location.address.adminDistrict; - if (includeStreetAddress) { - place.streetAddress = location.address.addressLine; - } + place.streetAddress = location.address.addressLine; } if (location.point && location.point.coordinates && location.point.coordinates.length == 2) { place.geo = new Geo(); - place.geo.latitude = location.point.coordinates[0]; - place.geo.longitude = location.point.coordinates[1]; + place.geo.latitude = location.point.coordinates[0].toString(); + place.geo.longitude = location.point.coordinates[1].toString(); } return place; } -export function buildPlaceFromGeo(latitude: string, longitude: string) { - var place = new Place(); - place.geo = new Geo(); - place.geo.latitude = latitude; - place.geo.longitude = longitude; +export function getFormattedAddressFromLocation(location: RawLocation, separator: string): string { + let addressParts: Array = new Array(); - return place; -} - -export function getFormattedAddressFromPlace(place: Place, separator: string): string { - var addressParts: Array = new Array(); - - if (place.streetAddress) { - addressParts.push(place.streetAddress); + if (location.address) { + addressParts = [ location.address.addressLine, + location.address.locality, + location.address.adminDistrict, + location.address.postalCode, + location.address.countryRegion]; } - if (place.locality) { - addressParts.push(place.locality); - } - - if (place.region) { - addressParts.push(place.region); - } - - if (place.postalCode) { - addressParts.push(place.postalCode); - } - - if (place.country) { - addressParts.push(place.country); - } - - return addressParts.join(separator); + return addressParts.filter(i => i).join(separator); } \ No newline at end of file diff --git a/Node/core/src/consts.ts b/Node/core/src/consts.ts index 86fe1b7..bfe52aa 100644 --- a/Node/core/src/consts.ts +++ b/Node/core/src/consts.ts @@ -3,6 +3,7 @@ export const LibraryName = "botbuilder-location"; export const Strings = { "AddressSeparator": "AddressSeparator", + "AddToFavoritesAsk": "AddToFavoritesAsk", "AskForEmptyAddressTemplate": "AskForEmptyAddressTemplate", "AskForPrefix": "AskForPrefix", "AskForTemplate": "AskForTemplate", @@ -10,15 +11,32 @@ export const Strings = { "ConfirmationAsk": "ConfirmationAsk", "Country": "Country", "DefaultPrompt": "DefaultPrompt", + "DeleteCommand": "DeleteCommand", + "DeleteFavoriteAbortion" : "DeleteFavoriteAbortion", + "DeleteFavoriteConfirmationAsk": "DeleteFavoriteConfirmationAsk", + "DialogStartBranchAsk": "DialogStartBranchAsk", + "EditCommand": "EditCommand", + "EditFavoritePrompt": "EditFavoritePrompt", + "EnterNewFavoriteLocationName": "EnterNewFavoriteLocationName", + "FavoriteAddedConfirmation": "FavoriteAddedConfirmation", + "FavoriteDeletedConfirmation": "FavoriteDeletedConfirmation", + "FavoriteEdittedConfirmation": "FavoriteEdittedConfirmation", + "FavoriteLocations": "FavoriteLocations", "HelpMessage": "HelpMessage", + "InvalidFavoriteLocationSelection": "InvalidFavoriteLocationSelection", "InvalidLocationResponse": "InvalidLocationResponse", "InvalidLocationResponseFacebook": "InvalidLocationResponseFacebook", + "InvalidStartBranchResponse": "InvalidStartBranchResponse", "LocationNotFound": "LocationNotFound", "Locality": "Locality", "MultipleResultsFound": "MultipleResultsFound", + "NoFavoriteLocationsFound": "NoFavoriteLocationsFound", + "OtherComand": "OtherComand", + "OtherLocation": "OtherLocation", "PostalCode": "PostalCode", "Region": "Region", "ResetPrompt": "ResetPrompt", + "SelectFavoriteLocationPrompt" : "SelectFavoriteLocationPrompt", "SingleResultFound": "SingleResultFound", "StreetAddress": "StreetAddress", "TitleSuffixFacebook": "TitleSuffixFacebook", diff --git a/Node/core/src/dialogs/add-favorite-location-dialog.ts b/Node/core/src/dialogs/add-favorite-location-dialog.ts new file mode 100644 index 0000000..0268eeb --- /dev/null +++ b/Node/core/src/dialogs/add-favorite-location-dialog.ts @@ -0,0 +1,59 @@ +import { IDialogResult, Library, Session } from 'botbuilder'; +import * as common from '../common'; +import { Strings } from '../consts'; +import { FavoriteLocation } from '../favorite-location'; +import { FavoritesManager } from '../services/favorites-manager'; + +export function register(library: Library): void { + library.dialog('add-favorite-location-dialog', createDialog()); + library.dialog('name-favorite-location-dialog', createNameFavoriteLocationDialog()); +} + +function createDialog() { + return [ + // Ask the user whether they want to add the location to their favorites, if applicable + (session: Session, args: any) => { + // check two cases: + // no capacity to add to favorites in the first place! + // OR the location is already marked as favorite + const favoritesManager = new FavoritesManager(session.userData); + if (favoritesManager.maxCapacityReached() || favoritesManager.isFavorite(args.place)) { + session.endDialogWithResult({ response: {} }); + } + else { + session.dialogData.place = args.place; + var addToFavoritesAsk = session.gettext(Strings.AddToFavoritesAsk) + session.beginDialog('confirm-dialog', { confirmationPrompt: addToFavoritesAsk }); + } + }, + // If the user confirmed, ask them to enter a name for the new favorite location + // Otherwise, we are done + (session: Session, results: IDialogResult, next: (results?: IDialogResult) => void) => { + // User does want to add a new favorite location + if (results.response && results.response.confirmed) { + session.beginDialog('name-favorite-location-dialog', { place: session.dialogData.place }); + } + else { + // User does NOT want to add a new favorite location + next(results); + } + } + ] +} + +function createNameFavoriteLocationDialog() { + return common.createBaseDialog() + .onBegin(function (session, args) { + session.dialogData.place = args.place; + session.send(session.gettext(Strings.EnterNewFavoriteLocationName)).sendBatch(); + }).onDefault((session) => { + const favoriteLocation: FavoriteLocation = { + location: session.dialogData.place, + name : session.message.text + }; + const favoritesManager = new FavoritesManager(session.userData); + favoritesManager.add(favoriteLocation); + session.send(session.gettext(Strings.FavoriteAddedConfirmation, favoriteLocation.name)); + session.endDialogWithResult({ response: {} }); + }); +} \ No newline at end of file diff --git a/Node/core/src/dialogs/choice-dialog.ts b/Node/core/src/dialogs/choose-location-dialog.ts similarity index 86% rename from Node/core/src/dialogs/choice-dialog.ts rename to Node/core/src/dialogs/choose-location-dialog.ts index 4315cc0..7608251 100644 --- a/Node/core/src/dialogs/choice-dialog.ts +++ b/Node/core/src/dialogs/choose-location-dialog.ts @@ -4,7 +4,7 @@ import { Strings } from '../consts'; import { Place } from '../place'; export function register(library: Library): void { - library.dialog('choice-dialog', createDialog()); + library.dialog('choose-location-dialog', createDialog()); } function createDialog() { @@ -23,8 +23,7 @@ function createDialog() { if (match) { var currentNumber = Number(match[0]); if (currentNumber > 0 && currentNumber <= session.dialogData.locations.length) { - var place = common.processLocation(session.dialogData.locations[currentNumber - 1], true); - session.endDialogWithResult({ response: { place: place } }); + session.endDialogWithResult({ response: { place: session.dialogData.locations[currentNumber - 1] } }); return; } } diff --git a/Node/core/src/dialogs/confirm-dialog.ts b/Node/core/src/dialogs/confirm-dialog.ts index 6474b17..928fb05 100644 --- a/Node/core/src/dialogs/confirm-dialog.ts +++ b/Node/core/src/dialogs/confirm-dialog.ts @@ -9,20 +9,18 @@ export function register(library: Library): void { function createDialog() { return common.createBaseDialog() .onBegin((session, args) => { - session.dialogData.locations = args.locations; - - session.send(Strings.SingleResultFound).sendBatch(); + var confirmationPrompt = args.confirmationPrompt; + session.send(confirmationPrompt).sendBatch(); }) .onDefault((session) => { var message = parseBoolean(session.message.text); if (typeof message == 'boolean') { var result: any; if (message == true) { - var place = common.processLocation(session.dialogData.locations[0], true); - result = { response: { place: place } }; + result = { response: { confirmed: true } }; } else { - result = { response: { reset: true } } + result = { response: { confirmed: false } }; } session.endDialogWithResult(result) diff --git a/Node/core/src/dialogs/confirm-single-location-dialog.ts b/Node/core/src/dialogs/confirm-single-location-dialog.ts new file mode 100644 index 0000000..1ea8333 --- /dev/null +++ b/Node/core/src/dialogs/confirm-single-location-dialog.ts @@ -0,0 +1,25 @@ +import { IDialogResult, Library, Session } from 'botbuilder'; +import { Strings } from '../consts'; + +export function register(library: Library): void { + library.dialog('confirm-single-location-dialog', createDialog()); +} + +function createDialog() { + return [ + (session: Session, args: any) => { + session.dialogData.locations = args.locations; + session.beginDialog('confirm-dialog', { confirmationPrompt: session.gettext(Strings.SingleResultFound) }); + }, + (session: Session, results: IDialogResult, next: (results?: IDialogResult) => void) => { + if (results.response && results.response.confirmed) { + // User did confirm the single location offered + session.endDialogWithResult({ response: { place: session.dialogData.locations[0] } }); + } + else { + // User said no + session.endDialogWithResult({ response: { reset: true } }); + } + } + ]; +} \ No newline at end of file diff --git a/Node/core/src/dialogs/delete-favorite-location-dialog.ts b/Node/core/src/dialogs/delete-favorite-location-dialog.ts new file mode 100644 index 0000000..4adcfba --- /dev/null +++ b/Node/core/src/dialogs/delete-favorite-location-dialog.ts @@ -0,0 +1,35 @@ +import { IDialogResult, Library, Session } from 'botbuilder'; +import { Strings } from '../consts'; +import { FavoritesManager } from '../services/favorites-manager'; + +export function register(library: Library, apiKey: string): void { + library.dialog('delete-favorite-location-dialog', createDialog()); +} + +function createDialog() { + return [ + // Ask the user to confirm deleting the favorite location + (session: Session, args: any) => { + session.dialogData.args = args; + session.dialogData.toBeDeleted = args.toBeDeleted; + const deleteFavoriteConfirmationAsk = session.gettext(Strings.DeleteFavoriteConfirmationAsk, args.toBeDeleted.name) + session.beginDialog('confirm-dialog', { confirmationPrompt: deleteFavoriteConfirmationAsk }); + }, + // Check whether the user confirmed + (session: Session, results: IDialogResult, next: (results?: IDialogResult) => void) => { + if (results.response && results.response.confirmed) { + const favoritesManager = new FavoritesManager(session.userData); + favoritesManager.delete(session.dialogData.toBeDeleted); + session.send(session.gettext(Strings.FavoriteDeletedConfirmation, session.dialogData.toBeDeleted.name)); + session.replaceDialog('retrieve-favorite-location-dialog', session.dialogData.args); + } + else if (results.response && results.response.confirmed === false) { + session.send(session.gettext(Strings.DeleteFavoriteAbortion)); + session.replaceDialog('retrieve-favorite-location-dialog', session.dialogData.args); + } + else { + next(results); + } + } + ] +} \ No newline at end of file diff --git a/Node/core/src/dialogs/edit-fravorite-location-dialog.ts b/Node/core/src/dialogs/edit-fravorite-location-dialog.ts new file mode 100644 index 0000000..e42aeaa --- /dev/null +++ b/Node/core/src/dialogs/edit-fravorite-location-dialog.ts @@ -0,0 +1,34 @@ +import { IDialogResult, Library, Session } from 'botbuilder'; +import { Strings } from '../consts'; +import { FavoriteLocation } from '../favorite-location'; +import { FavoritesManager } from '../services/favorites-manager'; + +export function register(library: Library, apiKey: string): void { + library.dialog('edit-favorite-location-dialog', createDialog()); +} + +function createDialog() { + return [ + (session: Session, args: any) => { + session.dialogData.args = args; + session.dialogData.toBeEditted = args.toBeEditted; + session.send(session.gettext(Strings.EditFavoritePrompt, args.toBeEditted.name)); + session.beginDialog('retrieve-location-dialog', session.dialogData.args); + }, + (session: Session, results: IDialogResult, next: (results?: IDialogResult) => void) => { + if (results.response && results.response.place) { + const favoritesManager = new FavoritesManager(session.userData); + const newfavoriteLocation: FavoriteLocation = { + location: results.response.place, + name: session.dialogData.toBeEditted.name + }; + favoritesManager.update(session.dialogData.toBeEditted, newfavoriteLocation); + session.send(session.gettext(Strings.FavoriteEdittedConfirmation, session.dialogData.toBeEditted.name)); + session.endDialogWithResult({ response: { place: results.response.place } }); + } + else { + next(results); + } + } + ] +} \ No newline at end of file diff --git a/Node/core/src/dialogs/required-fields-dialog.ts b/Node/core/src/dialogs/require-fields-dialog.ts similarity index 83% rename from Node/core/src/dialogs/required-fields-dialog.ts rename to Node/core/src/dialogs/require-fields-dialog.ts index 217f692..e253f3e 100644 --- a/Node/core/src/dialogs/required-fields-dialog.ts +++ b/Node/core/src/dialogs/require-fields-dialog.ts @@ -12,15 +12,15 @@ export enum LocationRequiredFields { } export function register(library: Library): void { - library.dialog('required-fields-dialog', createDialog()); + library.dialog('require-fields-dialog', createDialog()); } const fields: Array = [ - { name: "streetAddress", prompt: Strings.StreetAddress, flag: LocationRequiredFields.streetAddress }, + { name: "addressLine", prompt: Strings.StreetAddress, flag: LocationRequiredFields.streetAddress }, { name: "locality", prompt: Strings.Locality, flag: LocationRequiredFields.locality }, - { name: "region", prompt: Strings.Region, flag: LocationRequiredFields.region }, + { name: "adminDistrict", prompt: Strings.Region, flag: LocationRequiredFields.region }, { name: "postalCode", prompt: Strings.PostalCode, flag: LocationRequiredFields.postalCode }, - { name: "country", prompt: Strings.Country, flag: LocationRequiredFields.country }, + { name: "countryRegion", prompt: Strings.Country, flag: LocationRequiredFields.country }, ]; function createDialog() { @@ -44,7 +44,7 @@ function createDialog() { } session.dialogData.lastInput = session.message.text; - session.dialogData.place[fields[index].name] = session.message.text; + session.dialogData.place.address[fields[index].name] = session.message.text; } index++; @@ -60,6 +60,7 @@ function createDialog() { session.dialogData.index = index; if (index >= fields.length) { + session.endDialogWithResult({ response: { place: session.dialogData.place } }); } else { session.sendBatch(); @@ -68,12 +69,12 @@ function createDialog() { } function completeFieldIfMissing(session: Session, field: any) { - if ((field.flag & session.dialogData.requiredFieldsFlag) && !session.dialogData.place[field.name]) { + if ((field.flag & session.dialogData.requiredFieldsFlag) && !session.dialogData.place.address[field.name]) { var prefix: string = ""; var prompt: string = ""; if (typeof session.dialogData.lastInput === "undefined") { - var formattedAddress: string = common.getFormattedAddressFromPlace(session.dialogData.place, session.gettext(Strings.AddressSeparator)); + var formattedAddress: string = common.getFormattedAddressFromLocation(session.dialogData.place, session.gettext(Strings.AddressSeparator)); if (formattedAddress) { prefix = session.gettext(Strings.AskForPrefix, formattedAddress); prompt = session.gettext(Strings.AskForTemplate, session.gettext(field.prompt)); diff --git a/Node/core/src/dialogs/default-location-dialog.ts b/Node/core/src/dialogs/resolve-bing-location-dialog.ts similarity index 60% rename from Node/core/src/dialogs/default-location-dialog.ts rename to Node/core/src/dialogs/resolve-bing-location-dialog.ts index 249eb5c..4bc8bd3 100644 --- a/Node/core/src/dialogs/default-location-dialog.ts +++ b/Node/core/src/dialogs/resolve-bing-location-dialog.ts @@ -1,16 +1,15 @@ import * as common from '../common'; import { Strings } from '../consts'; -import { Session, IDialogResult, Library, AttachmentLayout, HeroCard, CardImage, Message } from 'botbuilder'; -import { Place } from '../Place'; -import { MapCard } from '../map-card' +import { Session, IDialogResult, Library } from 'botbuilder'; import * as locationService from '../services/bing-geospatial-service'; -import * as confirmDialog from './confirm-dialog'; -import * as choiceDialog from './choice-dialog'; +import * as confirmSingleLocationDialog from './confirm-single-location-dialog'; +import * as chooseLocationDialog from './choose-location-dialog'; +import { LocationCardBuilder } from '../services/location-card-builder'; export function register(library: Library, apiKey: string): void { - confirmDialog.register(library); - choiceDialog.register(library); - library.dialog('default-location-dialog', createDialog()); + confirmSingleLocationDialog.register(library); + chooseLocationDialog.register(library); + library.dialog('resolve-bing-location-dialog', createDialog()); library.dialog('location-resolve-dialog', createLocationResolveDialog(apiKey)); } @@ -25,9 +24,9 @@ function createDialog() { var locations = results.response.locations; if (locations.length == 1) { - session.beginDialog('confirm-dialog', { locations: locations }); + session.beginDialog('confirm-single-location-dialog', { locations: locations }); } else { - session.beginDialog('choice-dialog', { locations: locations }); + session.beginDialog('choose-location-dialog', { locations: locations }); } } else { @@ -55,37 +54,11 @@ function createLocationResolveDialog(apiKey: string) { var locationCount = Math.min(MAX_CARD_COUNT, locations.length); locations = locations.slice(0, locationCount); - var reply = createLocationsCard(apiKey, session, locations); + var reply = new LocationCardBuilder(apiKey).createHeroCards(session, locations); session.send(reply); session.endDialogWithResult({ response: { locations: locations } }); }) .catch(error => session.error(error)); }); -} - -function createLocationsCard(apiKey: string, session: Session, locations: any) { - var cards = new Array(); - - for (var i = 0; i < locations.length; i++) { - cards.push(constructCard(apiKey, session, locations, i)); - } - - return new Message(session) - .attachmentLayout(AttachmentLayout.carousel) - .attachments(cards); -} - -function constructCard(apiKey: string, session: Session, locations: Array, index: number): HeroCard { - var location = locations[index]; - var card = new MapCard(apiKey, session); - - if (locations.length > 1) { - card.location(location, index + 1); - } - else { - card.location(location); - } - - return card; } \ No newline at end of file diff --git a/Node/core/src/dialogs/facebook-location-dialog.ts b/Node/core/src/dialogs/retrieve-facebook-location-dialog.ts similarity index 80% rename from Node/core/src/dialogs/facebook-location-dialog.ts rename to Node/core/src/dialogs/retrieve-facebook-location-dialog.ts index 1eab5b1..8a527a8 100644 --- a/Node/core/src/dialogs/facebook-location-dialog.ts +++ b/Node/core/src/dialogs/retrieve-facebook-location-dialog.ts @@ -1,11 +1,11 @@ import { Strings } from '../consts'; import * as common from '../common'; -import { Session, IDialogResult, Library, AttachmentLayout, HeroCard, CardImage, Message } from 'botbuilder'; -import { Place } from '../Place'; +import { Session, IDialogResult, Library, Message } from 'botbuilder'; import * as locationService from '../services/bing-geospatial-service'; +import { RawLocation } from '../rawLocation'; export function register(library: Library, apiKey: string): void { - library.dialog('facebook-location-dialog', createDialog(apiKey)); + library.dialog('retrive-facebook-location-dialog', createDialog(apiKey)); library.dialog('facebook-location-resolve-dialog', createLocationResolveDialog()); } @@ -17,11 +17,11 @@ function createDialog(apiKey: string) { }, (session: Session, results: IDialogResult, next: (results?: IDialogResult) => void) => { if (session.dialogData.args.reverseGeocode && results.response && results.response.place) { - locationService.getLocationByPoint(apiKey, results.response.place.geo.latitude, results.response.place.geo.longitude) + locationService.getLocationByPoint(apiKey, results.response.place.point.coordinates[0], results.response.place.point.coordinates[1]) .then(locations => { - var place: Place; + var place: RawLocation; if (locations.length) { - place = common.processLocation(locations[0], false); + place = locations[0]; } else { place = results.response.place; } @@ -47,7 +47,7 @@ function createLocationResolveDialog() { var entities = session.message.entities; for (var i = 0; i < entities.length; i++) { if (entities[i].type == "Place" && entities[i].geo && entities[i].geo.latitude && entities[i].geo.longitude) { - session.endDialogWithResult({ response: { place: common.buildPlaceFromGeo(entities[i].geo.latitude, entities[i].geo.longitude) } }); + session.endDialogWithResult({ response: { place: buildLocationFromGeo(entities[i].geo.latitude, entities[i].geo.longitude) } }); return; } } @@ -69,4 +69,9 @@ function sendLocationPrompt(session: Session, prompt: string): Session { }); return session.send(message); +} + +function buildLocationFromGeo(latitude: string, longitude: string) { + let coordinates = [ latitude, longitude]; + return { point : { coordinates : coordinates } }; } \ No newline at end of file diff --git a/Node/core/src/dialogs/retrieve-favorite-location-dialog.ts b/Node/core/src/dialogs/retrieve-favorite-location-dialog.ts new file mode 100644 index 0000000..5d2df07 --- /dev/null +++ b/Node/core/src/dialogs/retrieve-favorite-location-dialog.ts @@ -0,0 +1,99 @@ +import * as common from '../common'; +import { Strings } from '../consts'; +import { Session, Library } from 'botbuilder'; +import { LocationCardBuilder } from '../services/location-card-builder'; +import { FavoritesManager } from '../services/favorites-manager'; +import * as deleteFavoriteLocationDialog from './delete-favorite-location-dialog'; +import * as editFavoriteLocationDialog from './edit-fravorite-location-dialog'; +import { RawLocation } from '../rawLocation'; + +export function register(library: Library, apiKey: string): void { + library.dialog('retrieve-favorite-location-dialog', createDialog(apiKey)); + deleteFavoriteLocationDialog.register(library, apiKey); + editFavoriteLocationDialog.register(library, apiKey); +} + +function createDialog(apiKey: string) { + return common.createBaseDialog() + .onBegin(function (session, args) { + session.dialogData.args = args; + const favoritesManager = new FavoritesManager(session.userData); + const userFavorites = favoritesManager.getFavorites(); + + // If the user has no favorite locations, switch to a normal location retriever dialog + if (userFavorites.length == 0) { + session.send(session.gettext(Strings.NoFavoriteLocationsFound)); + session.replaceDialog('retrieve-location-dialog', session.dialogData.args); + return; + } + + session.dialogData.userFavorites = userFavorites; + + let locations: RawLocation[] = []; + let names: string[] = []; + for (let i = 0; i < userFavorites.length; i++) { + locations.push(userFavorites[i].location); + names.push(userFavorites[i].name); + } + + session.send(new LocationCardBuilder(apiKey).createHeroCards(session, locations, true, names)); + session.send(session.gettext(Strings.SelectFavoriteLocationPrompt)).sendBatch(); + }).onDefault((session) => { + const text: string = session.message.text; + if (text === session.gettext(Strings.OtherComand)) { + session.replaceDialog('retrieve-location-dialog', session.dialogData.args); + } + else { + const selection = tryParseCommandSelection(text, session.dialogData.userFavorites.length); + if ( selection.command === "select" ) { + // complete required fields + session.replaceDialog('require-fields-dialog', { + place: session.dialogData.userFavorites[selection.index - 1].location, + requiredFields: session.dialogData.args.requiredFields + }); + } + else if (selection.command === session.gettext(Strings.DeleteCommand) ) { + session.dialogData.args.toBeDeleted = session.dialogData.userFavorites[selection.index - 1]; + session.replaceDialog('delete-favorite-location-dialog', session.dialogData.args); + } + else if (selection.command === session.gettext(Strings.EditCommand)) { + session.dialogData.args.toBeEditted = session.dialogData.userFavorites[selection.index - 1]; + session.replaceDialog('edit-favorite-location-dialog', session.dialogData.args); + } + else { + session.send(session.gettext(Strings.InvalidFavoriteLocationSelection)).sendBatch(); + } + } + }); +} + +function tryParseNumberSelection(text: string): number { + const tokens = text.trim().split(' '); + if (tokens.length == 1) { + const numberExp = /[+-]?(?:\d+\.?\d*|\d*\.?\d+)/; + const match = numberExp.exec(text); + if (match) { + return Number(match[0]); + } + } + return -1; +} + +function tryParseCommandSelection(text: string, maxIndex: number): any { + const tokens = text.trim().split(' '); + + if (tokens.length == 1) { + const index = tryParseNumberSelection(text); + if (index > 0 && index <= maxIndex) { + return { index: index, command: "select" }; + } + } + else if (tokens.length == 2) { + const index = tryParseNumberSelection(tokens[1]); + if (index > 0 && index <= maxIndex) { + return { index: index, command: tokens[0] }; + } + } + + return { command: ""}; +} \ No newline at end of file diff --git a/Node/core/src/dialogs/retrieve-location-dialog.ts b/Node/core/src/dialogs/retrieve-location-dialog.ts new file mode 100644 index 0000000..6d37753 --- /dev/null +++ b/Node/core/src/dialogs/retrieve-location-dialog.ts @@ -0,0 +1,34 @@ +import { IDialogResult, Library, Session } from 'botbuilder'; +import * as resolveBingLocationDialog from './resolve-bing-location-dialog'; +import * as retrieveFacebookLocationDialog from './retrieve-facebook-location-dialog'; + +export function register(library: Library, apiKey: string): void { + library.dialog('retrieve-location-dialog', createDialog()); + resolveBingLocationDialog.register(library, apiKey); + retrieveFacebookLocationDialog.register(library, apiKey); +} + +function createDialog() { + return [ + (session: Session, args: any) => { + session.dialogData.args = args; + if (args.useNativeControl && session.message.address.channelId == 'facebook') { + session.beginDialog('retrieve-facebook-location-dialog', args); + } + else { + session.beginDialog('resolve-bing-location-dialog', args); + } + }, + // complete required fields, if applicable + (session: Session, results: IDialogResult, next: (results?: IDialogResult) => void) => { + if (results.response && results.response.place) { + session.beginDialog('require-fields-dialog', { + place: results.response.place, + requiredFields: session.dialogData.args.requiredFields + }) + } else { + next(results); + } + } + ] +} \ No newline at end of file diff --git a/Node/core/src/favorite-location.ts b/Node/core/src/favorite-location.ts new file mode 100644 index 0000000..71a5a13 --- /dev/null +++ b/Node/core/src/favorite-location.ts @@ -0,0 +1,6 @@ +import { RawLocation } from './rawLocation'; + +export class FavoriteLocation { + name: string; + location: RawLocation; +} \ No newline at end of file diff --git a/Node/core/src/locale/en/botbuilder-location.json b/Node/core/src/locale/en/botbuilder-location.json index 042bf8e..e38b310 100644 --- a/Node/core/src/locale/en/botbuilder-location.json +++ b/Node/core/src/locale/en/botbuilder-location.json @@ -1,5 +1,6 @@ { "AddressSeparator": ", ", + "AddToFavoritesAsk": "Do you want me to add this address to your favorite locations?", "AskForEmptyAddressTemplate": "Please provide the %s.", "AskForPrefix": "OK %s.", "AskForTemplate": " Please also provide the %s.", @@ -7,15 +8,32 @@ "ConfirmationAsk": "OK, I will ship to %s. Is that correct? Enter 'yes' or 'no'.", "Country": "country", "DefaultPrompt": "Where should I ship your order?", + "DeleteCommand": "delete", + "DeleteFavoriteAbortion": "OK, deletion aborted.", + "DeleteFavoriteConfirmationAsk": "Are you sure you want to delete %s from your favorite locations?", + "DialogStartBranchAsk": "How would you like to pick a location?", + "EditCommand": "edit", + "EditFavoritePrompt": "OK, let's edit %s. Enter a new address.", + "EnterNewFavoriteLocationName": "OK, please enter a friendly name for this address. You can use 'home', 'work' or any other name you prefer.", + "FavoriteAddedConfirmation": "OK, I added %s to your favorite locations.", + "FavoriteDeletedConfirmation": "OK, I deleted %s from your favorite locations.", + "FavoriteEdittedConfirmation": "OK, I editted %s in your favorite locations with this new address.", + "FavoriteLocations": "Favorite Locations", "HelpMessage": "Say or type a valid address when asked, and I will try to find it using Bing. You can provide the full address information (street no. / name, city, region, postal/zip code, country) or a part of it. If you want to change the address, say or type 'reset'. Finally, say or type 'cancel' to exit without providing an address.", + "InvalidFavoriteLocationSelection": "Type or say a number to choose the address, enter 'other' to create a new favorite location, or enter 'cancel' to exit. You can also type or say 'edit' or 'delete' followed by a number to edit or delete the respective location.", "InvalidLocationResponse": "Didn't get that. Choose a location or cancel.", "InvalidLocationResponseFacebook": "Tap on Send Location to proceed; type or say cancel to exit.", + "InvalidStartBranchResponse": "Tap one of the options to proceed; type or say cancel to exit.", "LocationNotFound": "I could not find this address. Please try again.", "Locality": "city or locality", "MultipleResultsFound": "I found these results. Type or say a number to choose the address, or enter 'other' to select another address.", + "NoFavoriteLocationsFound": "You do not seem to have any favorite locations at the moment. Enter an address and you will be able to save it to your favorite locations.", + "OtherComand": "other", + "OtherLocation": "Other Location", "PostalCode": "zip or postal code", "Region": "state or region", "ResetPrompt": "OK, let's start over.", + "SelectFavoriteLocationPrompt": "Here are your favorite locations. Type or say a number to use the respective location, or 'other' to use a different location. You can also type or say 'edit' or 'delete' followed by a number to edit or delete the respective location.", "SingleResultFound": "I found this result. Is this the correct address?", "StreetAddress": "street address", "TitleSuffix": " Type or say an address", diff --git a/Node/core/src/map-card.ts b/Node/core/src/map-card.ts index 58445be..9030e42 100644 --- a/Node/core/src/map-card.ts +++ b/Node/core/src/map-card.ts @@ -1,5 +1,6 @@ import { Session, HeroCard, CardImage } from 'botbuilder'; import { Place, Geo } from './place'; +import { RawLocation } from './rawLocation' import * as locationService from './services/bing-geospatial-service'; export class MapCard extends HeroCard { @@ -8,14 +9,18 @@ export class MapCard extends HeroCard { super(session); } - public location(location: any, index?: number): this { - var indexText = ""; + public location(location: RawLocation, index?: number, locationName?: string): this { + var prefixText = ""; if (index !== undefined) { - indexText = index + ". "; + prefixText = index + ". "; } - this.subtitle(indexText + location.address.formattedAddress) + if (locationName !== undefined) { + prefixText += locationName + ": "; + } + this.subtitle(prefixText + location.address.formattedAddress); + if (location.point) { var locationUrl: string; try { diff --git a/Node/core/src/rawLocation.ts b/Node/core/src/rawLocation.ts new file mode 100644 index 0000000..60e85f2 --- /dev/null +++ b/Node/core/src/rawLocation.ts @@ -0,0 +1,23 @@ +export class RawLocation { + address: Address; + bbox: Array; + confidence: string; + entityType: string; + name: string; + point: Point; +} + +class Address { + addressLine: string; + adminDistrict: string; + adminDistrict2: string; + countryRegion: string; + formattedAddress: string; + locality: string; + postalCode: string; +} + +class Point { + coordinates: Array; + calculationMethod: string; +} \ No newline at end of file diff --git a/Node/core/src/services/bing-geospatial-service.ts b/Node/core/src/services/bing-geospatial-service.ts index 6fcf338..8164927 100644 --- a/Node/core/src/services/bing-geospatial-service.ts +++ b/Node/core/src/services/bing-geospatial-service.ts @@ -1,5 +1,6 @@ import * as rp from 'request-promise'; import { sprintf } from 'sprintf-js'; +import { RawLocation } from '../rawLocation' const formAugmentation = "&form=BTCTRL" const findLocationByQueryUrl = "https://dev.virtualearth.net/REST/v1/Locations?" + formAugmentation; @@ -7,18 +8,18 @@ const findLocationByPointUrl = "https://dev.virtualearth.net/REST/v1/Locations/% const findImageByPointUrl = "https://dev.virtualearth.net/REST/V1/Imagery/Map/Road/%1$s,%2$s/15?mapSize=500,280&pp=%1$s,%2$s;1;%3$s&dpi=1&logo=always" + formAugmentation; const findImageByBBoxUrl = "https://dev.virtualearth.net/REST/V1/Imagery/Map/Road?mapArea=%1$s,%2$s,%3$s,%4$s&mapSize=500,280&pp=%5$s,%6$s;1;%7$s&dpi=1&logo=always" + formAugmentation; -export function getLocationByQuery(apiKey: string, address: string): Promise> { +export function getLocationByQuery(apiKey: string, address: string): Promise> { var url = addKeyToUrl(findLocationByQueryUrl, apiKey) + "&q=" + encodeURIComponent(address); return getLocation(url); } -export function getLocationByPoint(apiKey: string, latitude: string, longitude: string): Promise> { +export function getLocationByPoint(apiKey: string, latitude: string, longitude: string): Promise> { var url: string = sprintf(findLocationByPointUrl, latitude, longitude); url = addKeyToUrl(url, apiKey) + "&q="; return getLocation(url); } -export function GetLocationMapImageUrl(apiKey: string, location: any, index?: number) { +export function GetLocationMapImageUrl(apiKey: string, location: RawLocation, index?: number) { if (location && location.point && location.point.coordinates && location.point.coordinates.length == 2) { var point = location.point; @@ -40,7 +41,7 @@ export function GetLocationMapImageUrl(apiKey: string, location: any, index?: nu throw "Invalid Location Format: " + location; } -function getLocation(url: string): Promise> { +function getLocation(url: string): Promise> { const requestData = { url: url, json: true diff --git a/Node/core/src/services/favorites-manager.ts b/Node/core/src/services/favorites-manager.ts new file mode 100644 index 0000000..d00b5aa --- /dev/null +++ b/Node/core/src/services/favorites-manager.ts @@ -0,0 +1,87 @@ +import { FavoriteLocation } from '../favorite-location'; +import { RawLocation } from '../rawLocation'; + +export class FavoritesManager { + + readonly maxFavoriteCount = 5; + readonly favoritesKey = 'favorites'; + + constructor (private userData : any) { + } + + public maxCapacityReached(): boolean { + return this.getFavorites().length >= this.maxFavoriteCount; + } + + public isFavorite(location: RawLocation) : boolean { + let favorites = this.getFavorites(); + + for (let i = 0; i < favorites.length; i++) { + if (this.areEqual(favorites[i].location, location)) { + return true; + } + } + + return false; + } + + public add(favoriteLocation: FavoriteLocation): void { + let favorites = this.getFavorites(); + + if (favorites.length >= this.maxFavoriteCount) { + throw ('The max allowed number of favorite locations has already been reached.'); + } + + favorites.push(favoriteLocation); + this.userData[this.favoritesKey] = favorites; + } + + public delete(favoriteLocation: FavoriteLocation): void { + let favorites = this.getFavorites(); + let newFavorites = []; + + for (let i = 0; i < favorites.length; i++) { + if ( !this.areEqual(favorites[i].location, favoriteLocation.location)) { + newFavorites.push(favorites[i]); + } + } + + this.userData[this.favoritesKey] = newFavorites; + } + + public update(currentValue: FavoriteLocation, newValue: FavoriteLocation): void { + let favorites = this.getFavorites(); + let newFavorites = []; + + for (let i = 0; i < favorites.length; i++) { + if ( this.areEqual(favorites[i].location, currentValue.location)) { + newFavorites.push(newValue); + } + else { + newFavorites.push(favorites[i]); + } + } + + this.userData[this.favoritesKey] = newFavorites; + } + + public getFavorites(): FavoriteLocation[] { + let storedFavorites = this.userData[this.favoritesKey]; + + if (storedFavorites) { + return storedFavorites; + } + else { + // User currently has no favorite locations. Return an empty list. + return []; + } + } + + private areEqual(location0: RawLocation, location1: RawLocation): boolean { + // Other attributes of a location such as its Confidence, BoundaryBox, etc + // should not be considered as distinguishing factors. + // On the other hand, attributes of a location that are shown to the users + // are what distinguishes one location from another. + return location0.address.formattedAddress === location1.address.formattedAddress; + } +} \ No newline at end of file diff --git a/Node/core/src/services/location-card-builder.ts b/Node/core/src/services/location-card-builder.ts new file mode 100644 index 0000000..f35d629 --- /dev/null +++ b/Node/core/src/services/location-card-builder.ts @@ -0,0 +1,42 @@ +import { AttachmentLayout, HeroCard, Message, Session } from 'botbuilder'; +import { MapCard } from '../map-card' +import {RawLocation} from '../rawLocation' + +export class LocationCardBuilder { + + constructor (private apiKey : string) { + } + + public createHeroCards(session: Session, locations: Array, alwaysShowNumericPrefix?: boolean, locationNames?: Array): Message { + let cards = new Array(); + + for (let i = 0; i < locations.length; i++) { + cards.push(this.constructCard(session, locations, i, alwaysShowNumericPrefix, locationNames)); + } + + return new Message(session) + .attachmentLayout(AttachmentLayout.carousel) + .attachments(cards); + } + + private constructCard(session: Session, locations: Array, index: number, alwaysShowNumericPrefix?: boolean, locationNames?: Array): HeroCard { + const location = locations[index]; + let card = new MapCard(this.apiKey, session); + + if (alwaysShowNumericPrefix || locations.length > 1) { + if (locationNames) + { + card.location(location, index + 1, locationNames[index]); + } + else + { + card.location(location, index + 1); + } + } + else { + card.location(location); + } + + return card; + } +} \ No newline at end of file diff --git a/Node/sample/app.js b/Node/sample/app.js index 9b8f523..ba47ecb 100644 --- a/Node/sample/app.js +++ b/Node/sample/app.js @@ -26,6 +26,8 @@ bot.dialog("/", [ prompt: "Where should I ship your order?", useNativeControl: true, reverseGeocode: true, + skipFavorites: false, + skipConfirmationAsk: true, requiredFields: locationDialog.LocationRequiredFields.streetAddress | locationDialog.LocationRequiredFields.locality | @@ -39,7 +41,13 @@ bot.dialog("/", [ function (session, results) { if (results.response) { var place = results.response; - session.send("Thanks, I will ship to " + locationDialog.getFormattedAddressFromPlace(place, ", ")); + var formattedAddress = + session.send("Thanks, I will ship to " + getFormattedAddressFromPlace(place, ", ")); } } -]); \ No newline at end of file +]); + +function getFormattedAddressFromPlace(place, separator) { + var addressParts = [place.streetAddress, place.locality, place.region, place.postalCode, place.country]; + return addressParts.filter(i => i).join(separator); +} \ No newline at end of file