diff --git a/README.md b/README.md index aa6ac66..261aa3b 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,52 @@ Make sure to provide required configuration values. The app currently needs a va "MicrosoftAppPassword": "" } ``` +Description: +* LuisAppID: AppId for the app you created within luis.ai +* LuisAPIKey: API Key you set for your LUIS service under portal.azure.com +* LuisAPIHostName: Hostname from portal.azure.com without https://! +* ProactiveBotApiKey: The API Key you need to trigger https://:3978/api/timesheet/remind (pass the API-Key as header info "ProactiveBotApiKey") +* MicrosoftAppId: The AppId of your WebApplication you get from portal.azure.com +* MicrosoftAppPassword: The password for your application. You also get this from portal.azure.com +* KeyVaultName: the name of your Vault storage for storing the tokens + +Important: if you test the bot locally, you should use a reduced set of settings: + +```json +{ + "LuisAppId": "", + "LuisAPIKey": "", + "LuisAPIHostName": "", + "ProactiveBotApiKey": "" +} +``` +You still need the LUIS service to be active. + +### LUIS +For proper operation, you must provide a LUIS model. This can be done at luis.ai + +#### Intents +You must create different intents. The intents below are the ones that i've figured out to be the minimum. +Add also @datetimeV2 as a feature. + +![images/img1.JPG](images/img1.JPG) + +![images/img2.jpg](images/img2.jpg) +![images/img3.jpg](images/img3.jpg) + +#### Entities +You need also at least one additional entity called "WorkedEntity". This stores the project you have worked on. + +![images/img4.jpg](images/img4.jpg) + +### Auto reminder + +The auto reminder is triggered by an endpoint. You have to call [GET] http://localhost:3978/api/timesheet/remind and pass ProactiveBotApiKey within the header and pass as value the "ProactiveBotApiKey" value. + +### Clockify +The first time you contact the bot, he will ask you for your clockify API-Key and stores it within the KeyVault. + +### Run Then run the bot. For example, from a terminal: diff --git a/images/img1.JPG b/images/img1.JPG new file mode 100644 index 0000000..ec11cae Binary files /dev/null and b/images/img1.JPG differ diff --git a/images/img2.jpg b/images/img2.jpg new file mode 100644 index 0000000..af3a589 Binary files /dev/null and b/images/img2.jpg differ diff --git a/images/img3.jpg b/images/img3.jpg new file mode 100644 index 0000000..c2d41b1 Binary files /dev/null and b/images/img3.jpg differ diff --git a/images/img4.jpg b/images/img4.jpg new file mode 100644 index 0000000..646100c Binary files /dev/null and b/images/img4.jpg differ diff --git a/src/Clockify/ClockifyController.cs b/src/Clockify/ClockifyController.cs index 6194fe5..865e214 100644 --- a/src/Clockify/ClockifyController.cs +++ b/src/Clockify/ClockifyController.cs @@ -1,7 +1,10 @@ -using System.Threading.Tasks; +using System; +using System.Linq; +using System.Threading.Tasks; using Bot.Remind; using Bot.Security; using Microsoft.AspNetCore.Mvc; +using Microsoft.Azure.Cosmos.Linq; using Microsoft.Bot.Builder; using Microsoft.Bot.Builder.Integration.AspNet.Core; @@ -11,18 +14,19 @@ namespace Bot.Clockify public class ClockifyController : ControllerBase { private readonly IProactiveBotApiKeyValidator _proactiveBotApiKeyValidator; - private readonly IRemindService _entryFillRemindService; + private readonly ISpecificRemindService _entryFillRemindService; private readonly IBotFrameworkHttpAdapter _adapter; private readonly IFollowUpService _followUpService; public ClockifyController(IBotFrameworkHttpAdapter adapter, - IProactiveBotApiKeyValidator proactiveBotApiKeyValidator, IRemindServiceResolver remindServiceResolver, + IProactiveBotApiKeyValidator proactiveBotApiKeyValidator, + ISpecificRemindServiceResolver specificRemindServiceResolver, IFollowUpService followUpService) { _adapter = adapter; _proactiveBotApiKeyValidator = proactiveBotApiKeyValidator; _followUpService = followUpService; - _entryFillRemindService = remindServiceResolver.Resolve("EntryFill"); + _entryFillRemindService = specificRemindServiceResolver.Resolve("EntryFill"); } [Route("api/timesheet/remind")] @@ -32,7 +36,32 @@ public async Task GetTimesheetRemindAsync() string apiToken = ProactiveApiKeyUtil.Extract(Request); _proactiveBotApiKeyValidator.Validate(apiToken); - return await _entryFillRemindService.SendReminderAsync(_adapter); + //Only use TodayReminder as default to be compatible to the old behaviour of the endpoint + var typesToRemind = SpecificRemindService.ReminderType.TodayReminder; + + bool respectWorkingHours = true; + + //Check, whether we should disturb the employee even if it is the mid of the day + if (Request.Query.ContainsKey("respectWorkingHours")) + { + if (Request.Query["respectWorkingHours"].Contains("true")) respectWorkingHours = true; + if (Request.Query["respectWorkingHours"].Contains("false")) respectWorkingHours = false; + } + + //Check for additional query parameters. If there are available, we will only remind those reminders + if (Request.Query.ContainsKey("type")) + { + var requestedReminderTypes = Request.Query["type"]; + //Check for the specific teminder types + typesToRemind = SpecificRemindService.ReminderType.NoReminder; + if (requestedReminderTypes.Contains("yesterday")) + typesToRemind |= SpecificRemindService.ReminderType.YesterdayReminder; + + if (requestedReminderTypes.Contains("today")) + typesToRemind |= SpecificRemindService.ReminderType.TodayReminder; + } + + return await _entryFillRemindService.SendReminderAsync(_adapter, typesToRemind, respectWorkingHours); } [Route("api/follow-up")] @@ -42,7 +71,7 @@ public async Task SendFollowUpAsync() string apiToken = ProactiveApiKeyUtil.Extract(Request); _proactiveBotApiKeyValidator.Validate(apiToken); - var followedUsers = await _followUpService.SendFollowUpAsync((BotAdapter)_adapter); + var followedUsers = await _followUpService.SendFollowUpAsync((BotAdapter)_adapter); return $"Sent follow up to {followedUsers.Count} users"; } diff --git a/src/Clockify/ClockifyHandler.cs b/src/Clockify/ClockifyHandler.cs index cc564b7..1ca783c 100644 --- a/src/Clockify/ClockifyHandler.cs +++ b/src/Clockify/ClockifyHandler.cs @@ -1,8 +1,11 @@ using System; +using System.Collections.Generic; +using System.Linq; using System.Threading; using System.Threading.Tasks; using Bot.Clockify.Fill; using Bot.Clockify.Reports; +using Bot.Clockify.User; using Bot.Common.Recognizer; using Bot.States; using Bot.Supports; @@ -15,25 +18,33 @@ public class ClockifyHandler : IBotHandler { private readonly EntryFillDialog _fillDialog; private readonly ReportDialog _reportDialog; + private readonly UserSettingsDialog _userSettingsDialog; private readonly StopReminderDialog _stopReminderDialog; private readonly ClockifySetupDialog _clockifySetupDialog; + private readonly LogoutDialog _logoutDialog; private readonly DialogSet _dialogSet; private readonly IStatePropertyAccessor _dialogState; + private readonly IEnumerable _logoutIntent = new HashSet { "log out", "logout" }; + public ClockifyHandler(EntryFillDialog fillDialog, ReportDialog reportDialog, - StopReminderDialog stopReminderDialog, ConversationState conversationState, - ClockifySetupDialog clockifySetupDialog) + StopReminderDialog stopReminderDialog, UserSettingsDialog userSettingsDialog, ConversationState conversationState, + ClockifySetupDialog clockifySetupDialog, LogoutDialog logoutDialog) { _dialogState = conversationState.CreateProperty("ClockifyDialogState"); _fillDialog = fillDialog; _reportDialog = reportDialog; + _userSettingsDialog = userSettingsDialog; _stopReminderDialog = stopReminderDialog; _clockifySetupDialog = clockifySetupDialog; + _logoutDialog = logoutDialog; _dialogSet = new DialogSet(_dialogState) .Add(_fillDialog) .Add(_stopReminderDialog) + .Add(_userSettingsDialog) .Add(_reportDialog) - .Add(_clockifySetupDialog); + .Add(_clockifySetupDialog) + .Add(_logoutDialog); } public async Task Handle(ITurnContext turnContext, CancellationToken cancellationToken, @@ -47,11 +58,21 @@ public async Task Handle(ITurnContext turnContext, CancellationToken cance var dialogContext = await _dialogSet.CreateContextAsync(turnContext, cancellationToken); if (await RunClockifySetupIfNeeded(turnContext, cancellationToken, userProfile)) return true; + + //Check for fixed intents without using LUIS + if (_logoutIntent.Contains(turnContext.Activity.Text)) + { + await dialogContext.BeginDialogAsync(_logoutDialog.Id, cancellationToken: cancellationToken); + return true; + } try { switch (luisResult.TopIntentWithMinScore()) { + case TimeSurveyBotLuis.Intent.SetWorkingHours: + await dialogContext.BeginDialogAsync(_userSettingsDialog.Id, luisResult, cancellationToken); + return true; case TimeSurveyBotLuis.Intent.Report: await dialogContext.BeginDialogAsync(_reportDialog.Id, luisResult, cancellationToken); return true; diff --git a/src/Clockify/ClockifyMessageSource.cs b/src/Clockify/ClockifyMessageSource.cs index 2938c9a..0965c74 100644 --- a/src/Clockify/ClockifyMessageSource.cs +++ b/src/Clockify/ClockifyMessageSource.cs @@ -24,6 +24,8 @@ public ClockifyMessageSource(IStringLocalizer localizer) public string TaskCreation => GetString(nameof(TaskCreation)); public string TaskAbort => GetString(nameof(TaskAbort)); public string AddEntryFeedback => GetString(nameof(AddEntryFeedback)); + public string SetWorkingHoursFeedback => GetString(nameof(SetWorkingHoursFeedback)); + public string SetWorkingHoursUnchangedFeedback => GetString(nameof(SetWorkingHoursUnchangedFeedback)); public string EntryFillUnderstandingError => GetString(nameof(EntryFillUnderstandingError)); public string AmbiguousProjectError => GetString(nameof(AmbiguousProjectError)); public string ProjectUnrecognized => GetString(nameof(ProjectUnrecognized)); @@ -40,12 +42,19 @@ public ClockifyMessageSource(IStringLocalizer localizer) public string ReportDateRangeExceedOneYear => GetString(nameof(ReportDateRangeExceedOneYear)); public string FollowUp => GetString(nameof(FollowUp)); + + public string LogoutPrompt => GetString(nameof(LogoutPrompt)); + public string LogoutYes => GetString(nameof(LogoutYes)); + public string LogoutNo => GetString(nameof(LogoutNo)); + public string LogoutRetryPrompt => GetString(nameof(LogoutRetryPrompt)); public string RemindStoppedAlready => GetString(nameof(RemindStoppedAlready)); public string RemindStopAnswer => GetString(nameof(RemindStopAnswer)); public string RemindEntryFill => GetString(nameof(RemindEntryFill)); + public string RemindEntryFillYesterday => GetString(nameof(RemindEntryFillYesterday)); + private string GetString(string name) { if (!_localizer[name].ResourceNotFound) return _localizer[name].Value; diff --git a/src/Clockify/EntryFillRemindService.cs b/src/Clockify/EntryFillRemindService.cs index a673169..99201e8 100644 --- a/src/Clockify/EntryFillRemindService.cs +++ b/src/Clockify/EntryFillRemindService.cs @@ -8,30 +8,14 @@ namespace Bot.Clockify { - public class EntryFillRemindService : GenericRemindService + public class EntryFillRemindService : SpecificRemindService { - private static BotCallbackHandler BotCallbackMaker(Func getResource) - { - return async (turn, token) => - { - string text = getResource(); - if (Uri.IsWellFormedUriString(text, UriKind.RelativeOrAbsolute)) - { - // TODO: support other content types - await turn.SendActivityAsync(MessageFactory.Attachment(new Attachment("image/png", text)), token); - } - else - { - await turn.SendActivityAsync(MessageFactory.Text(text), token); - } - }; - } public EntryFillRemindService(IUserProfilesProvider userProfilesProvider, IConfiguration configuration, ICompositeNeedReminderService compositeNeedRemindService, IClockifyMessageSource messageSource, ILogger logger) : base(userProfilesProvider, configuration, compositeNeedRemindService, - BotCallbackMaker(() => messageSource.RemindEntryFill), logger) + messageSource, logger) { } } diff --git a/src/Clockify/IClockifyMessageSource.cs b/src/Clockify/IClockifyMessageSource.cs index a99f508..c39807b 100644 --- a/src/Clockify/IClockifyMessageSource.cs +++ b/src/Clockify/IClockifyMessageSource.cs @@ -11,6 +11,8 @@ public interface IClockifyMessageSource string TaskCreation { get; } string TaskAbort { get; } string AddEntryFeedback { get; } + string SetWorkingHoursFeedback { get; } + string SetWorkingHoursUnchangedFeedback { get; } string EntryFillUnderstandingError { get; } string AmbiguousProjectError { get; } string ProjectUnrecognized { get; } @@ -29,7 +31,14 @@ public interface IClockifyMessageSource string RemindStoppedAlready { get; } string RemindStopAnswer { get; } string RemindEntryFill { get; } + + string RemindEntryFillYesterday { get; } string FollowUp { get; } + + string LogoutPrompt { get; } + string LogoutYes { get; } + string LogoutNo { get; } + string LogoutRetryPrompt { get; } } } \ No newline at end of file diff --git a/src/Clockify/LogoutDialog.cs b/src/Clockify/LogoutDialog.cs new file mode 100644 index 0000000..ca912dd --- /dev/null +++ b/src/Clockify/LogoutDialog.cs @@ -0,0 +1,98 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Bot.Data; +using Bot.States; +using Microsoft.Bot.Builder; +using Microsoft.Bot.Builder.Dialogs; +using Microsoft.Bot.Schema; + +namespace Bot.Clockify +{ + public class LogoutDialog : ComponentDialog + { + private const string LogoutWaterfall = nameof(LogoutWaterfall); + private readonly UserState _userState; + private readonly IClockifyMessageSource _messageSource; + private readonly ITokenRepository _tokenRepository; + + private const string Yes = "yes"; + private const string No = "no"; + + public LogoutDialog(UserState userState, IClockifyMessageSource messageSource, ITokenRepository tokenRepository) + { + _userState = userState; + _messageSource = messageSource; + _tokenRepository = tokenRepository; + AddDialog(new WaterfallDialog(LogoutWaterfall, new List + { + ConfirmationStep, + LogoutStep + })); + AddDialog(new TextPrompt(nameof(ConfirmationStep), LogoutValidator)); + Id = nameof(LogoutDialog); + } + + private async Task ConfirmationStep(WaterfallStepContext stepContext, + CancellationToken cancellationToken) + { + var suggestions = new List + { + new CardAction + { + Title = Yes, Type = ActionTypes.MessageBack, Text = Yes, Value = Yes, + DisplayText = Yes + }, + new CardAction + { + Title = No, Type = ActionTypes.MessageBack, Text = No, Value = No, + DisplayText = No + } + }; + var activity = MessageFactory.Text(_messageSource.LogoutPrompt); + activity.SuggestedActions = new SuggestedActions { Actions = suggestions }; + return await stepContext.PromptAsync(nameof(ConfirmationStep), new PromptOptions + { + Prompt = activity, + RetryPrompt = MessageFactory.Text(_messageSource.LogoutRetryPrompt), + }, cancellationToken); + } + + private async Task LogoutStep(WaterfallStepContext stepContext, + CancellationToken cancellationToken) + { + var result = stepContext.Result.ToString(); + switch (result?.ToLower()) + { + case Yes: + var userProfile = + await StaticUserProfileHelper.GetUserProfileAsync(_userState, stepContext.Context, + cancellationToken); + + //Removes the token from the repository! This change reflects immediateley also within all caches + //and also on the remote key vault! + await _tokenRepository.RemoveAsync(userProfile.ClockifyTokenId!); + + //Now we can also remove the tokenID from the UserProfile + userProfile.ClockifyTokenId = null; + await stepContext.Context.SendActivityAsync( + MessageFactory.Text(_messageSource.LogoutYes), cancellationToken); + break; + case No: + await stepContext.Context.SendActivityAsync(MessageFactory.Text(_messageSource.LogoutNo), + cancellationToken); + break; + } + + return await stepContext.EndDialogAsync(null, cancellationToken); + } + + private static Task LogoutValidator(PromptValidatorContext promptContext, + CancellationToken cancellationToken) + { + string? pValue = promptContext.Recognized.Value; + return Task.FromResult(!string.IsNullOrEmpty(pValue) && + (pValue.ToLower() == Yes || pValue.ToLower() == No)); + } + } +} \ No newline at end of file diff --git a/src/Clockify/PastDayNotComplete.cs b/src/Clockify/PastDayNotComplete.cs new file mode 100644 index 0000000..3c4d64a --- /dev/null +++ b/src/Clockify/PastDayNotComplete.cs @@ -0,0 +1,86 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using Bot.Clockify.Client; +using Bot.Common; +using Bot.Data; +using Bot.Remind; +using Bot.States; + +namespace Bot.Clockify +{ + public class PastDayNotComplete : INeedRemindService + { + private readonly IClockifyService _clockifyService; + private readonly ITokenRepository _tokenRepository; + private readonly IDateTimeProvider _dateTimeProvider; + + //Get de default hours to work. If not defined, assume 8hours + public static readonly string DefaultWorkingHours = + Environment.GetEnvironmentVariable("DEFAULT_WORKING_HOURS") ?? "8"; + + //Get the minimum percentage of hours filled. If not defined, assume 75% of a default work day to be reported. + //This leads to 6 hours + public static readonly string MinimumHoursFilledPercentage = + Environment.GetEnvironmentVariable("MINIMUM_HOURS_FILLED_PERCENTAGE") ?? "75"; + + public PastDayNotComplete(IClockifyService clockifyService, ITokenRepository tokenRepository, + IDateTimeProvider dateTimeProvider) + { + _clockifyService = clockifyService; + _tokenRepository = tokenRepository; + _dateTimeProvider = dateTimeProvider; + } + + public async Task ReminderIsNeeded(UserProfile userProfile) + { + try + { + var tokenData = await _tokenRepository.ReadAsync(userProfile.ClockifyTokenId!); + string clockifyToken = tokenData.Value; + string userId = userProfile.UserId ?? throw new ArgumentNullException(nameof(userProfile.UserId)); + var workspaces = await _clockifyService.GetWorkspacesAsync(clockifyToken); + + TimeZoneInfo userTimeZone = userProfile.TimeZone; + var userNow = TimeZoneInfo.ConvertTime(_dateTimeProvider.DateTimeUtcNow(), userTimeZone); + + var userStartDay = userNow.Date.AddDays(-1); //Get past day + + //Check for weekends. If we got one, go back in time. + while (userStartDay.DayOfWeek == DayOfWeek.Sunday || userStartDay.DayOfWeek == DayOfWeek.Saturday) + { + userStartDay = userStartDay.AddDays(-1); //Go back in time till we have no weekend anymore + } + var userEndDay = userStartDay.AddDays(1); //Add one day to the startDay for a 1 day range + + double totalHoursInserted = (await Task.WhenAll(workspaces.Select(ws => + _clockifyService.GetHydratedTimeEntriesAsync(clockifyToken, ws.Id, userId, userStartDay, + userEndDay)))) + .SelectMany(p => p) + .Sum(e => + { + if (e.TimeInterval.End != null && e.TimeInterval.Start != null) + { + return (e.TimeInterval.End.Value - e.TimeInterval.Start.Value).TotalHours; + } + + return 0; + }); + + //Check if we have defined the working hours on user level. If so, calculate the minimum. + if (userProfile.WorkingHours != null) + return totalHoursInserted < + (userProfile.WorkingHours * (double.Parse(MinimumHoursFilledPercentage) / 100)); + + //Calculate the minimum amount of hours to be reported based on the defaults. + return totalHoursInserted < (double.Parse(DefaultWorkingHours) * + (double.Parse(MinimumHoursFilledPercentage) / 100)); + + } + catch (Exception) + { + return false; + } + } + } +} \ No newline at end of file diff --git a/src/Clockify/Reports/ReportUtil.cs b/src/Clockify/Reports/ReportUtil.cs index 6f486c1..3f92bd4 100644 --- a/src/Clockify/Reports/ReportUtil.cs +++ b/src/Clockify/Reports/ReportUtil.cs @@ -3,6 +3,7 @@ using System.Text; using Bot.Clockify.Models; using Microsoft.Bot.Connector; +using Microsoft.Recognizers.Text; namespace Bot.Clockify.Reports { diff --git a/src/Clockify/TimeSheetNotFullEnough.cs b/src/Clockify/TimeSheetNotFullEnough.cs index 2fbb9b7..6974fac 100644 --- a/src/Clockify/TimeSheetNotFullEnough.cs +++ b/src/Clockify/TimeSheetNotFullEnough.cs @@ -15,6 +15,15 @@ public class TimeSheetNotFullEnough : INeedRemindService private readonly ITokenRepository _tokenRepository; private readonly IDateTimeProvider _dateTimeProvider; + //Get de default hours to work. If not defined, assume 8hours + public static readonly string DefaultWorkingHours = + Environment.GetEnvironmentVariable("DEFAULT_WORKING_HOURS") ?? "8"; + + //Get the minimum percentage of hours filled. If not defined, assume 75% of a default work day to be reported. + //This leads to 6 hours + public static readonly string MinimumHoursFilledPercentage = + Environment.GetEnvironmentVariable("MINIMUM_HOURS_FILLED_PERCENTAGE") ?? "75"; + public TimeSheetNotFullEnough(IClockifyService clockifyService, ITokenRepository tokenRepository, IDateTimeProvider dateTimeProvider) { @@ -50,7 +59,15 @@ public async Task ReminderIsNeeded(UserProfile userProfile) return 0; }); - return totalHoursInserted < 6; + + //Check if we have defined the working hours on user level. If so, calculate the minimum. + if (userProfile.WorkingHours != null) + return totalHoursInserted < + (userProfile.WorkingHours * (double.Parse(MinimumHoursFilledPercentage) / 100)); + + //Calculate the minimum amount of hours to be reported based on the defaults. + return totalHoursInserted < (double.Parse(DefaultWorkingHours) * + (double.Parse(MinimumHoursFilledPercentage) / 100)); } catch (Exception) { diff --git a/src/Clockify/User/UserSettingsDialog.cs b/src/Clockify/User/UserSettingsDialog.cs new file mode 100644 index 0000000..f37f47f --- /dev/null +++ b/src/Clockify/User/UserSettingsDialog.cs @@ -0,0 +1,116 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Bot.Clockify.Client; +using Bot.Clockify.Fill; +using Bot.Clockify.Models; +using Bot.Common; +using Bot.Common.ChannelData.Telegram; +using Bot.Common.Recognizer; +using Bot.Data; +using Bot.States; +using Microsoft.Bot.Builder; +using Microsoft.Bot.Builder.Dialogs; +using Microsoft.Bot.Schema; +using Microsoft.Extensions.Logging; +using Newtonsoft.Json; + +namespace Bot.Clockify.User +{ + public class UserSettingsDialog : ComponentDialog + { + private readonly ITokenRepository _tokenRepository; + private readonly ITimeEntryStoreService _timeEntryStoreService; + private readonly UserState _userState; + private readonly IClockifyMessageSource _messageSource; + private readonly IDateTimeProvider _dateTimeProvider; + private readonly ILogger _logger; + + private const string TaskWaterfall = "TaskWaterfall"; + + private const string Telegram = "telegram"; + + public UserSettingsDialog(ITimeEntryStoreService timeEntryStoreService, + UserState userState, ITokenRepository tokenRepository, + IClockifyMessageSource messageSource, IDateTimeProvider dateTimeProvider, + ILogger logger) + { + _timeEntryStoreService = timeEntryStoreService; + _userState = userState; + _tokenRepository = tokenRepository; + _messageSource = messageSource; + _dateTimeProvider = dateTimeProvider; + _logger = logger; + AddDialog(new WaterfallDialog(TaskWaterfall, new List + { + PromptForTaskAsync + })); + Id = nameof(UserSettingsDialog); + } + + private async Task PromptForTaskAsync(WaterfallStepContext stepContext, + CancellationToken cancellationToken) + { + string messageText = ""; + + var userProfile = + await StaticUserProfileHelper.GetUserProfileAsync(_userState, stepContext.Context, cancellationToken); + var tokenData = await _tokenRepository.ReadAsync(userProfile.ClockifyTokenId!); + string clockifyToken = tokenData.Value; + stepContext.Values["ClockifyTokenId"] = userProfile.ClockifyTokenId; + var luisResult = (TimeSurveyBotLuis)stepContext.Options; + + var workingMinutes = luisResult.WorkedDurationInMinutes(); + var workingHours = workingMinutes / 60; + + //Default messageText + messageText = string.Format(_messageSource.SetWorkingHoursFeedback, workingHours); + + //Check if there is a need for a change + if (userProfile.WorkingHours != null) + { + if (userProfile.WorkingHours == workingHours) + messageText = string.Format(_messageSource.SetWorkingHoursUnchangedFeedback, workingHours); + } + + //Store the working hours within the userProfile + userProfile.WorkingHours = workingHours; + + //Inform user and exit the conversation. + return await InformAndExit(stepContext, cancellationToken, messageText); + } + + + private async Task InformAndExit(DialogContext stepContext, + CancellationToken cancellationToken, string messageText) + { + string platform = stepContext.Context.Activity.ChannelId; + var ma = GetExitMessageActivity(messageText, platform); + await stepContext.Context.SendActivityAsync(ma, cancellationToken); + return await stepContext.EndDialogAsync(cancellationToken: cancellationToken); + } + + + private static IMessageActivity GetExitMessageActivity(string messageText, string platform) + { + IMessageActivity ma; + switch (platform.ToLower()) + { + case Telegram: + ma = Activity.CreateMessageActivity(); + var sendMessageParams = new SendMessageParameters(messageText, new ReplyKeyboardRemove()); + var channelData = new SendMessage(sendMessageParams); + ma.ChannelData = JsonConvert.SerializeObject(channelData); + return ma; + default: + ma = MessageFactory.Text(messageText); + ma.SuggestedActions = new SuggestedActions { Actions = new List() }; + return ma; + } + + ; + } + } +} \ No newline at end of file diff --git a/src/Common/Recognizer/TimeSurveyBotLuis.cs b/src/Common/Recognizer/TimeSurveyBotLuis.cs index f893286..7677c37 100644 --- a/src/Common/Recognizer/TimeSurveyBotLuis.cs +++ b/src/Common/Recognizer/TimeSurveyBotLuis.cs @@ -29,7 +29,8 @@ public enum Intent { Report, Thanks, Utilities_Help, - Utilities_Stop + Utilities_Stop, + SetWorkingHours }; [JsonProperty("intents")] public Dictionary Intents; diff --git a/src/Common/Resources/Clockify.ClockifyMessageSource.resx b/src/Common/Resources/Clockify.ClockifyMessageSource.resx index 3660ce1..9fec44b 100644 --- a/src/Common/Resources/Clockify.ClockifyMessageSource.resx +++ b/src/Common/Resources/Clockify.ClockifyMessageSource.resx @@ -157,4 +157,25 @@ Please don't exceed one year window Hey 👋 I noticed you never setup a Clockify token...{0}{0}Help me help you! Once you're setup I will assist you in your daily time tracking. + + You have not filled in all of your hours for yesterday. Please do so! + + + Ok, I set your daily working hours to {0:0} hours. Happy working 🤖 + + + Your daily working hours were already set to {0:0} hours 😅. Nothing changed. + + + Are you sure? + + + You have successfully logged out of Clockify! + + + As you wish! + + + Please answer with 'Yes' or 'No' + \ No newline at end of file diff --git a/src/Data/ITokenRepository.cs b/src/Data/ITokenRepository.cs index 39d803d..b089d79 100644 --- a/src/Data/ITokenRepository.cs +++ b/src/Data/ITokenRepository.cs @@ -11,6 +11,15 @@ public interface ITokenRepository /// The token value /// The token could not be found. Task ReadAsync(string id); + + + /// + /// Removes the token data starting from a string identifier + /// + /// The token identifier + /// a boolean success indicator + /// The token could not be found. + Task RemoveAsync(string id); /// diff --git a/src/Data/InMemoryTokenRepository.cs b/src/Data/InMemoryTokenRepository.cs index aeeaa4c..b1537fa 100644 --- a/src/Data/InMemoryTokenRepository.cs +++ b/src/Data/InMemoryTokenRepository.cs @@ -1,17 +1,43 @@ using System; using System.Collections.Concurrent; +using System.Collections.Generic; +using System.IO; using System.Threading.Tasks; +using Newtonsoft.Json; namespace Bot.Data { public class InMemoryTokenRepository : ITokenRepository { - private readonly ConcurrentDictionary _store = new ConcurrentDictionary(); + private ConcurrentDictionary _store = new ConcurrentDictionary(); + + private bool SaveStorageToFile() + { + var jsonStorage = JsonConvert.SerializeObject(_store); + try + { + File.WriteAllText("jsonStorage.json",jsonStorage); + } + catch (Exception e) + { + throw new Exception("Error during writing of local storage with message: "+ e.Message); + } + + return true; + } + public Task ReadAsync(string id) { if (id == null) throw new ArgumentNullException(id); + //If local storage exists, load it! + if (File.Exists("jsonStorage.json")) + { + var jsonStorage = File.ReadAllText("jsonStorage.json"); + _store = JsonConvert.DeserializeObject>(jsonStorage); + } + if (!_store.TryGetValue(id, out var value)) { throw new TokenNotFoundException("No token has been found with id " + id); @@ -19,12 +45,26 @@ public Task ReadAsync(string id) return Task.FromResult(new TokenData(id, value)); } + public Task RemoveAsync(string id) + { + if (!_store.TryGetValue(id, out var _)) + { + throw new TokenNotFoundException("No token has been found with id " + id); + } + + //Removes the key from the store. + _store.Remove(id, out _); + SaveStorageToFile(); + return Task.FromResult(true); + } + public Task WriteAsync(string value, string? id = null) { if (string.IsNullOrWhiteSpace(value)) throw new ArgumentException(value); string name = id ?? Guid.NewGuid().ToString(); _store.AddOrUpdate(name, value, (key, current) => value); + SaveStorageToFile(); return Task.FromResult(new TokenData(name, value)); } } diff --git a/src/Data/TokenRepository.cs b/src/Data/TokenRepository.cs index 9f8f461..809481a 100644 --- a/src/Data/TokenRepository.cs +++ b/src/Data/TokenRepository.cs @@ -44,10 +44,38 @@ public async Task ReadAsync(string id) } } + public async Task RemoveAsync(string id) + { + if (id == null) throw new ArgumentNullException(id); + + //First check, whether we have the key locally stored within our cache. If so, remove it! + if (_cache.TryGetValue(id, out var _)) + { + _cache.Remove(id); + } + + //Next start to delete the secret from our key vault. + try + { + await _secretClient.StartDeleteSecretAsync(id); + } + catch (RequestFailedException e) + { + if (e.Status == 404) + { + throw new TokenNotFoundException("No token has been found with id " + id); + } + + throw; + } + + return true; + } + public async Task WriteAsync(string value, string? id = null) { if (string.IsNullOrWhiteSpace(value)) throw new ArgumentException(value); - + string name = id ?? Guid.NewGuid().ToString(); KeyVaultSecret secret = await _secretClient.SetSecretAsync(name, value); return CacheAndGetTokenData(secret); @@ -56,7 +84,8 @@ public async Task WriteAsync(string value, string? id = null) private TokenData CacheAndGetTokenData(KeyVaultSecret secret) { var tokenData = new TokenData(secret.Name, secret.Value); - _cache.Set(tokenData.Id, tokenData, new MemoryCacheEntryOptions { SlidingExpiration = TimeSpan.FromSeconds(_cacheSeconds) }); + _cache.Set(tokenData.Id, tokenData, + new MemoryCacheEntryOptions { SlidingExpiration = TimeSpan.FromSeconds(_cacheSeconds) }); return tokenData; } } diff --git a/src/Remind/CompositeNeedReminderService.cs b/src/Remind/CompositeNeedReminderService.cs index c004ed4..e9442b0 100644 --- a/src/Remind/CompositeNeedReminderService.cs +++ b/src/Remind/CompositeNeedReminderService.cs @@ -1,13 +1,16 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using Bot.Clockify; +using Bot.DIC; using Bot.States; namespace Bot.Remind { public interface ICompositeNeedReminderService { - Task ReminderIsNeeded(UserProfile profile); + Task ReminderIsNeeded(UserProfile profile); } public class CompositeNeedReminderService: ICompositeNeedReminderService @@ -19,10 +22,32 @@ public CompositeNeedReminderService(IEnumerable services) _services = services; } - public async Task ReminderIsNeeded(UserProfile profile) + public async Task ReminderIsNeeded(UserProfile profile) { - bool[] conditions = await Task.WhenAll(_services.Select(service => service.ReminderIsNeeded(profile))); - return conditions.All(c => c); + var reminder = SpecificRemindService.ReminderType.NoReminder; + + //Check every reminder within all services + foreach (var service in _services) + { + var serviceType = typeof(PastDayNotComplete); + var reminderIsNeeded = await service.ReminderIsNeeded(profile); + + //Check if the particular reminder was set to true + if (reminderIsNeeded) + { + //The reminder for this service is needed, check why it is needed and set the flags + if (service.GetType() == typeof(PastDayNotComplete)) reminder |= SpecificRemindService.ReminderType.YesterdayReminder; + if (service.GetType() == typeof(TimeSheetNotFullEnough)) reminder |= SpecificRemindService.ReminderType.TodayReminder; + } + else + { + //The reminder for this service is not needed. Therefore we check, what was negative and set the appropriate flag! + if (service.GetType() == typeof(EndOfWorkingDay)) reminder |= SpecificRemindService.ReminderType.OutOfWorkTime; + if (service.GetType() == typeof(UserDidNotSayStop)) reminder |= SpecificRemindService.ReminderType.UserSaidStop; + if (service.GetType() == typeof(NotOnLeave)) reminder |= SpecificRemindService.ReminderType.UserOnLeave; + } + } + return reminder; } } } \ No newline at end of file diff --git a/src/Remind/GenericRemindService.cs b/src/Remind/GenericRemindService.cs index c645336..e2a0795 100644 --- a/src/Remind/GenericRemindService.cs +++ b/src/Remind/GenericRemindService.cs @@ -37,13 +37,14 @@ public async Task SendReminderAsync(IBotFrameworkHttpAdapter adapter) { var reminderCounter = 0; - async Task ReminderNeeded(UserProfile u) => await _compositeNeedRemindService.ReminderIsNeeded(u); + async Task ReminderNeeded(UserProfile u) => await _compositeNeedRemindService.ReminderIsNeeded(u); List userProfiles = await _userProfilesProvider.GetUserProfilesAsync(); + //Fetch all users where the ReminderType is not set to "NoReminder" List userToRemind = userProfiles .Where(u => u.ClockifyTokenId != null && u.ConversationReference != null) - .Where(u => ReminderNeeded(u).Result) + .Where(u => ReminderNeeded(u).Result != SpecificRemindService.ReminderType.NoReminder) .ToList(); foreach (var userProfile in userToRemind) diff --git a/src/Remind/ISpecificRemindService.cs b/src/Remind/ISpecificRemindService.cs new file mode 100644 index 0000000..642f6f9 --- /dev/null +++ b/src/Remind/ISpecificRemindService.cs @@ -0,0 +1,11 @@ +using System.Threading.Tasks; +using Microsoft.Bot.Builder.Integration.AspNet.Core; + +namespace Bot.Remind +{ + public interface ISpecificRemindService + { + Task SendReminderAsync(IBotFrameworkHttpAdapter adapter, SpecificRemindService.ReminderType reminderTypes, + bool respectWorkHours); + } +} \ No newline at end of file diff --git a/src/Remind/SpecificRemindService.cs b/src/Remind/SpecificRemindService.cs new file mode 100644 index 0000000..0123c1f --- /dev/null +++ b/src/Remind/SpecificRemindService.cs @@ -0,0 +1,158 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Bot.Clockify; +using Bot.States; +using Microsoft.Bot.Builder; +using Microsoft.Bot.Builder.Integration.AspNet.Core; +using Microsoft.Bot.Schema; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; + +namespace Bot.Remind +{ + public abstract class SpecificRemindService : ISpecificRemindService + { + private readonly IUserProfilesProvider _userProfilesProvider; + private readonly IClockifyMessageSource _messageSource; + private readonly ICompositeNeedReminderService _compositeNeedRemindService; + private readonly string _appId; + private readonly ILogger _logger; + + private static BotCallbackHandler BotCallbackMaker(Func getResource) + { + return async (turn, token) => + { + string text = getResource(); + if (Uri.IsWellFormedUriString(text, UriKind.RelativeOrAbsolute)) + { + // TODO: support other content types + await turn.SendActivityAsync(MessageFactory.Attachment(new Attachment("image/png", text)), token); + } + else + { + await turn.SendActivityAsync(MessageFactory.Text(text), token); + } + }; + } + + protected SpecificRemindService(IUserProfilesProvider userProfilesProvider, IConfiguration configuration, + ICompositeNeedReminderService compositeNeedReminderService, IClockifyMessageSource messageSource, + ILogger logger) + { + _userProfilesProvider = userProfilesProvider; + _compositeNeedRemindService = compositeNeedReminderService; + _messageSource = messageSource; + _logger = logger; + _appId = configuration["MicrosoftAppId"]; + if (string.IsNullOrEmpty(_appId)) + { + _appId = Guid.NewGuid().ToString(); + } + } + + [Flags] + public enum ReminderType + { + NoReminder = 0, + TodayReminder = 1, + YesterdayReminder = 2, + WeekReminder = 4, + OutOfWorkTime = 8, + UserSaidStop = 16, + UserOnLeave = 32 + }; + + + private bool SendSpecificReminderType(IBotFrameworkHttpAdapter adapter, UserProfile userProfile, + ReminderType reminderType) + { + var callback = BotCallbackMaker(() => _messageSource.RemindEntryFill); + switch (reminderType) + { + case ReminderType.TodayReminder: + callback = BotCallbackMaker(() => _messageSource.RemindEntryFill); + break; + + case ReminderType.YesterdayReminder: + callback = BotCallbackMaker(() => _messageSource.RemindEntryFillYesterday); + break; + } + + try + { + //TODO Change _botCallback according to the reminder type + ((BotAdapter)adapter).ContinueConversationAsync( + _appId, + userProfile!.ConversationReference, + callback, + default).Wait(1000); + } + catch (Exception e) + { + // Just logging the exception is sufficient, we do not want to stop other reminders. + _logger.LogError(e, "Reminder not sent for user {UserId}", userProfile.UserId); + return false; + } + + return true; + } + + public async Task SendReminderAsync(IBotFrameworkHttpAdapter adapter, ReminderType typesToRemind, + bool respectWorkHours) + { + var reminderCounter = 0; + var userCounter = 0; + //Check, whether we need to remind at least one event + if (typesToRemind != ReminderType.NoReminder) + { + async Task ReminderNeeded(UserProfile u) => + await _compositeNeedRemindService.ReminderIsNeeded(u); + + List userProfiles = await _userProfilesProvider.GetUserProfilesAsync(); + + //Search for all users where a reminder was set to something else than "NoReminder" + List validUsers = userProfiles + .Where(u => u.ClockifyTokenId != null && u.ConversationReference != null) + .ToList(); + + foreach (var userProfile in validUsers) + { + var userReminderTypes = ReminderNeeded(userProfile).Result; + + //Check if we need to remind the user + if (userReminderTypes != ReminderType.NoReminder) + { + //Check if we are out of working hours and we also want to check for this, break. + if (userReminderTypes.HasFlag(ReminderType.OutOfWorkTime) && respectWorkHours) + { + break; + } + + //Only upcount for users, which are not affected by the "OutOfWorkTime" condition + userCounter++; + + //Check, if the user needs a reminder for today and if we also have requested a reminder for today. + if (userReminderTypes.HasFlag(ReminderType.TodayReminder) && + typesToRemind.HasFlag(ReminderType.TodayReminder)) + { + if (SendSpecificReminderType(adapter, userProfile, ReminderType.TodayReminder)) + reminderCounter++; + } + + //Check, if the user needs a reminder for yesterday and if we also have requested a reminder for yesterday. + if (userReminderTypes.HasFlag(ReminderType.YesterdayReminder) && + typesToRemind.HasFlag(ReminderType.YesterdayReminder)) + { + if (SendSpecificReminderType(adapter, userProfile, ReminderType.YesterdayReminder)) + reminderCounter++; + } + } + } + } + + return $"Sent {reminderCounter} reminder to {userCounter} users"; + } + } +} \ No newline at end of file diff --git a/src/Remind/SpecificRemindServiceResolver.cs b/src/Remind/SpecificRemindServiceResolver.cs new file mode 100644 index 0000000..d788967 --- /dev/null +++ b/src/Remind/SpecificRemindServiceResolver.cs @@ -0,0 +1,25 @@ +using System.Collections.Generic; +using System.Linq; + +namespace Bot.Remind +{ + public interface ISpecificRemindServiceResolver + { + ISpecificRemindService Resolve(string name); + } + + public class SpecificRemindServiceResolver: ISpecificRemindServiceResolver + { + private readonly IEnumerable _remindServices; + + public SpecificRemindServiceResolver(IEnumerable remindServices) + { + _remindServices = remindServices; + } + + public ISpecificRemindService Resolve(string name) + { + return _remindServices.Single(p => p.GetType().ToString().Contains(name)); + } + } +} \ No newline at end of file diff --git a/src/Startup.cs b/src/Startup.cs index d15c4c9..51d4851 100644 --- a/src/Startup.cs +++ b/src/Startup.cs @@ -6,6 +6,7 @@ using Bot.Clockify.Client; using Bot.Clockify.Fill; using Bot.Clockify.Reports; +using Bot.Clockify.User; using Bot.Common; using Bot.Common.Recognizer; using Bot.Data; @@ -62,6 +63,8 @@ public void ConfigureServices(IServiceCollection services) services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); @@ -81,8 +84,10 @@ public void ConfigureServices(IServiceCollection services) services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); - services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/States/UserProfile.cs b/src/States/UserProfile.cs index 30a26c8..68b62a1 100644 --- a/src/States/UserProfile.cs +++ b/src/States/UserProfile.cs @@ -20,6 +20,8 @@ public class UserProfile public string? LastName { get; set; } public ConversationReference? ConversationReference { get; set; } + + public double? WorkingHours { get; set; } public DateTime? StopRemind { get; set; } public bool Experimental { get; set; }