Skip to content
Open
46 changes: 46 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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://<yourbot>: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:

Expand Down
Binary file added images/img1.JPG
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added images/img2.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added images/img3.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added images/img4.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
41 changes: 35 additions & 6 deletions src/Clockify/ClockifyController.cs
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -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")]
Expand All @@ -32,7 +36,32 @@ public async Task<string> 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")]
Expand All @@ -42,7 +71,7 @@ public async Task<string> 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";
}
Expand Down
9 changes: 8 additions & 1 deletion src/Clockify/ClockifyHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
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;
Expand All @@ -15,23 +16,26 @@ 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 DialogSet _dialogSet;
private readonly IStatePropertyAccessor<DialogState> _dialogState;

public ClockifyHandler(EntryFillDialog fillDialog, ReportDialog reportDialog,
StopReminderDialog stopReminderDialog, ConversationState conversationState,
StopReminderDialog stopReminderDialog, UserSettingsDialog userSettingsDialog, ConversationState conversationState,
ClockifySetupDialog clockifySetupDialog)
{
_dialogState = conversationState.CreateProperty<DialogState>("ClockifyDialogState");
_fillDialog = fillDialog;
_reportDialog = reportDialog;
_userSettingsDialog = userSettingsDialog;
_stopReminderDialog = stopReminderDialog;
_clockifySetupDialog = clockifySetupDialog;
_dialogSet = new DialogSet(_dialogState)
.Add(_fillDialog)
.Add(_stopReminderDialog)
.Add(_userSettingsDialog)
.Add(_reportDialog)
.Add(_clockifySetupDialog);
}
Expand All @@ -52,6 +56,9 @@ public async Task<bool> Handle(ITurnContext turnContext, CancellationToken cance
{
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;
Expand Down
4 changes: 4 additions & 0 deletions src/Clockify/ClockifyMessageSource.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ public ClockifyMessageSource(IStringLocalizer<ClockifyMessageSource> 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));
Expand All @@ -46,6 +48,8 @@ public ClockifyMessageSource(IStringLocalizer<ClockifyMessageSource> 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;
Expand Down
20 changes: 2 additions & 18 deletions src/Clockify/EntryFillRemindService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,30 +8,14 @@

namespace Bot.Clockify
{
public class EntryFillRemindService : GenericRemindService
public class EntryFillRemindService : SpecificRemindService
{
private static BotCallbackHandler BotCallbackMaker(Func<string> 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<EntryFillRemindService> logger) :
base(userProfilesProvider, configuration, compositeNeedRemindService,
BotCallbackMaker(() => messageSource.RemindEntryFill), logger)
messageSource, logger)
{
}
}
Expand Down
4 changes: 4 additions & 0 deletions src/Clockify/IClockifyMessageSource.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand All @@ -29,6 +31,8 @@ public interface IClockifyMessageSource
string RemindStoppedAlready { get; }
string RemindStopAnswer { get; }
string RemindEntryFill { get; }

string RemindEntryFillYesterday { get; }

string FollowUp { get; }
}
Expand Down
86 changes: 86 additions & 0 deletions src/Clockify/PastDayNotComplete.cs
Original file line number Diff line number Diff line change
@@ -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<bool> 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;
}
}
}
}
1 change: 1 addition & 0 deletions src/Clockify/Reports/ReportUtil.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
using System.Text;
using Bot.Clockify.Models;
using Microsoft.Bot.Connector;
using Microsoft.Recognizers.Text;

namespace Bot.Clockify.Reports
{
Expand Down
19 changes: 18 additions & 1 deletion src/Clockify/TimeSheetNotFullEnough.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
{
Expand Down Expand Up @@ -50,7 +59,15 @@ public async Task<bool> 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)
{
Expand Down
Loading