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/ClockifyMessageSource.cs b/src/Clockify/ClockifyMessageSource.cs index 2938c9a..007d397 100644 --- a/src/Clockify/ClockifyMessageSource.cs +++ b/src/Clockify/ClockifyMessageSource.cs @@ -46,6 +46,8 @@ public ClockifyMessageSource(IStringLocalizer localizer) 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..398a119 100644 --- a/src/Clockify/IClockifyMessageSource.cs +++ b/src/Clockify/IClockifyMessageSource.cs @@ -29,6 +29,8 @@ public interface IClockifyMessageSource string RemindStoppedAlready { get; } string RemindStopAnswer { get; } string RemindEntryFill { get; } + + string RemindEntryFillYesterday { get; } string FollowUp { get; } } diff --git a/src/Clockify/PastDayNotComplete.cs b/src/Clockify/PastDayNotComplete.cs new file mode 100644 index 0000000..a301fa4 --- /dev/null +++ b/src/Clockify/PastDayNotComplete.cs @@ -0,0 +1,68 @@ +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; + + 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; + }); + return totalHoursInserted < 6; + } + 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/Common/Resources/Clockify.ClockifyMessageSource.resx b/src/Common/Resources/Clockify.ClockifyMessageSource.resx index 3660ce1..60f3d31 100644 --- a/src/Common/Resources/Clockify.ClockifyMessageSource.resx +++ b/src/Common/Resources/Clockify.ClockifyMessageSource.resx @@ -157,4 +157,7 @@ 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! + \ No newline at end of file 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..80b53ed 100644 --- a/src/Startup.cs +++ b/src/Startup.cs @@ -81,8 +81,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();