Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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