This is the second in a five-post series, where we explore the Bot Builder C# SDK v4:

  1. How does a Bot Builder v4 bot work?
  2. How to send proactive messages with Bot Builder v4? (This article)
  3. How to receive events in a Bot Framework SDK v4 Web API bot?
  4. How to test a Bot Framework SDK v4 bot?
  5. How to do integration testing for a Bot Framework SDK v4 event bot?

In this post we’ll continue with the bot we completed in the previous post to add the ability to send proactive messages.

A proactive message is just one that’s sent without the user typing anything, so it’s not a reply to user input.

In the previous post we realized that the BotFrameworkAdapter is responsible of sending a message to the Bot Service/Emulator, so it’s received by the user.

We’ll add a new feature so our bot can set a timer and then notify the user when it goes off.

WARNING: The code shown here is experimental and has not been tested in production, so handle with care!

Source code

Overview

You might want to read the overview section of the previous post, as we’re building on it.

For this scenario, we have a Timer class that uses a ConversationReference to call the ContinueConversationAsync method in the adapter, to send the message back to the emulator.

The ConversationReference is stored in the Timer when the bot receives the command to start it, and this identifies the conversation that will receive the proactive message..

When the user sends a TIMER <seconds> message, a new instance of Timer is created, and then started in a separate thread, so it runs independently until if finishes. When the time’s up the Timer sends a message back to the user.

All timer instances are “managed” in class Timers, registered as a singleton, that keeps the collection of timers and has a List property that’s used to report on the timer list, when the user sends a TIMERS message.

Implementation details

The Controller

In this case we add a few lines to the BotController to handle the new feature as shown here:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
[ApiController]
public class BotController : ControllerBase
{
    private readonly ILogger<BotController> _logger;
    private readonly IAdapterIntegration _adapter;
    private readonly Timers _timers;

    public BotController(
        ILogger<BotController> logger,
        IAdapterIntegration adapter,
        Timers timers)
    {
        _logger = logger ?? throw new ArgumentNullException(nameof(logger));
        _adapter = adapter ?? throw new ArgumentNullException(nameof(adapter));
        _timers = timers ?? throw new ArgumentNullException(nameof(timers));
    }

    [HttpPost("/simple-bot/messages")]
    public async Task<InvokeResponse> Messages([FromBody]Activity activity)
    {
        _logger.LogTrace("----- BotController - Receiving activity: {@Activity}", activity);

        return await _adapter.ProcessActivityAsync(string.Empty, activity, OnTurnAsync, default);
    }

    private async Task OnTurnAsync(ITurnContext turnContext, CancellationToken cancellationToken)
    {
        if (turnContext.Activity.Type == ActivityTypes.Message)
        {
            var text = turnContext.Activity.Text.Trim();

            _logger.LogInformation("----- Receiving message activity - Text: {Text}", text);

            if (text.StartsWith("timer ", StringComparison.InvariantCultureIgnoreCase))
            {
                var seconds = Convert.ToInt32(text.Substring(text.IndexOf(" ")));

                await turnContext.SendActivityAsync($"Starting a {seconds}s timer");

                _timers.AddTimer(turnContext.Activity.GetConversationReference(), seconds);
            }
            else if (text.StartsWith("timers", StringComparison.InvariantCultureIgnoreCase))
            {
                var alarms = string.Join("\n", _timers.List.Select(a => $"- #{a.Number} [{a.Seconds}s] - {a.Status} ({a.Elapsed / 1000:n3}s)"));

                await turnContext.SendActivityAsync($"**TIMERS**\n{alarms}");
            }
            else
            {
                // Echo back to the user whatever they typed.
                await turnContext.SendActivityAsync($"You typed \"{text}\"");
            }
        }
        else
        {
            await turnContext.SendActivityAsync($"{turnContext.Activity.Type} event detected");
        }
    }
}

In detail:

  • The timer collection class is constructor-injected into the class (line 11).

  • When the user sends a TIMER message (line 34) we do a simple parsing and create a timer, getting the conversation reference (line 40) while in the conversation context.

  • Getting the conversation reference is the key to send proactive messages.

  • The TIMERS command is handled in line 42.

The Timer collection

The TimerCollection “manages” the timers through the AddTimer method and keeps track of them, as shown here:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
public class Timers
{
    private readonly IAdapterIntegration _adapter;

    public Timers(IAdapterIntegration adapter)
    {
        _adapter = adapter;
    }

    public List<Timer> List { get; set; } = new List<Timer>();

    public void AddTimer(ConversationReference reference, int seconds)
    {
        var timer = new Timer(_adapter, reference, seconds, List.Count + 1);

        Task.Run(() => timer.Start());

        List.Add(timer);
    }
}

This class:

  • Gets the adapter injected (line 5).

  • Creates the timer with a reference to the adapter and the conversation (line 14), so it can send the message through the adapter.

  • Starts the timer in a separate thread, to run until finished independently (line 16).

The Timer class

Finally, the Timer class is pretty straight forward:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
public class Timer
{
    private readonly IAdapterIntegration _adapter;
    private readonly ILogger _logger;

    public Timer(
        IAdapterIntegration adapter,
        ConversationReference conversationReference,
        int seconds,
        int number)
    {
        _adapter = adapter;
        _logger = Log.ForContext<Timer>();

        ConversationReference = conversationReference;
        Seconds = seconds;
        Number = number;
    }

    public ConversationReference ConversationReference { get; }
    public double Elapsed => ((FinishedAt ?? DateTime.Now) - (StartedAt ?? DateTime.Now)).TotalMilliseconds;
    public DateTime? FinishedAt { get; private set; }
    public int Number { get; }
    public int Seconds { get; }
    public DateTime? StartedAt { get; private set; }
    public string Status { get; private set; } = "Started";

    public async Task Start()
    {
        _logger.Information("----- Timer #{Number} [{Duration}s] started", Number);

        StartedAt = DateTime.Now;
        Status = "Running";

        await Task.Delay(Seconds * 1000);

        FinishedAt = DateTime.Now;
        Status = "Finished";

        _logger.Information("----- Timer #{Number} [{Duration}s] finished ({Elapsed:n3}s)", Number, Seconds, Elapsed / 1000);

        await _adapter.ContinueConversationAsync("not-important-for-emulator", ConversationReference, SendMessageAsync);
    }

    private async Task SendMessageAsync(ITurnContext turnContext, CancellationToken cancellationToken)
    {
        await turnContext.SendActivityAsync($"Timer #{Number} finished! ({Seconds})s");
    }
}

Details explained here:

  • The timer needs a reference to the BotFrameworkAdapter (line 7) and the ConversationReference (line 8), as required by the ContinueConversationAsync method from the adapter.

  • The timer waits asynchronously (line 35).

  • And finally calls the ContinueConversationAsync adapter method (line 42)

  • That invokes the SendMessageAsync timer method (line 45).

Setting up Startup.cs

The only thing missing is registering the Timers class in ConfigureServices with:

services.AddSingleton<Timers>();

Testing with the emulator

So we’re now ready to test with the Bot Emulator and we should get something like this:

And we should also get log events similar to these:

Refactoring

As a finishing touch, we’ll now refactor the bot to a class of its own, since the controller is getting way too complex, taking care of matter out of its purpose.

The refactoring is quite straight forward and results in the following classes.

BotController

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
[ApiController]
public class BotController : ControllerBase
{
    private readonly ILogger<BotController> _logger;
    private readonly IAdapterIntegration _adapter;
    private readonly IBot _bot;

    public BotController(
        ILogger<BotController> logger,
        IAdapterIntegration adapter,
        IBot bot)
    {
        _logger = logger ?? throw new ArgumentNullException(nameof(logger));
        _adapter = adapter ?? throw new ArgumentNullException(nameof(adapter));
        _bot = bot ?? throw new ArgumentNullException(nameof(bot));
    }

    [HttpPost("/simple-bot/messages")]
    public async Task<InvokeResponse> Messages([FromBody]Activity activity)
    {
        _logger.LogTrace("----- BotController - Receiving activity: {@Activity}", activity);

        return await _adapter.ProcessActivityAsync(string.Empty, activity, _bot.OnTurnAsync, default);
    }

Here we can see that now the bot is injected in line 11 and its OrTurnAsync method called in line 23.

ProactiveBot

This is pretty much the same that was in the controllers, so I think it doesn’t need any more explanation

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
public class ProactiveBot : IBot
{
    private readonly ILogger<ProactiveBot> _logger;
    private readonly Timers _timers;

    public ProactiveBot(
        ILogger<ProactiveBot> logger,
        Timers timers)
    {
        _logger = logger ?? throw new ArgumentNullException(nameof(logger));
        _timers = timers ?? throw new ArgumentNullException(nameof(timers));
    }

    public async Task OnTurnAsync(ITurnContext turnContext, CancellationToken cancellationToken = default)
    {
        if (turnContext.Activity.Type == ActivityTypes.Message)
        {
            var text = turnContext.Activity.Text.Trim();

            _logger.LogInformation("----- Receiving message activity - Text: {Text}", text);

            if (text.StartsWith("timer", StringComparison.InvariantCultureIgnoreCase))
            {
                var seconds = Convert.ToInt32(text.Substring(text.IndexOf(" ")));

                await turnContext.SendActivityAsync($"Starting a timer to go off in {seconds}s");

                _timers.AddTimer(turnContext.Activity.GetConversationReference(), seconds);
            }
            else if (text.StartsWith("list", StringComparison.InvariantCultureIgnoreCase))
            {
                var alarms = string.Join("\n", _timers.List.Select(a => $"- #{a.Number} [{a.Seconds}s] - {a.Status} ({a.Elapsed / 1000:n3}s)"));

                await turnContext.SendActivityAsync($"**TIMERS**\n{alarms}");
            }
            else
            {
                // Echo back to the user whatever they typed.
                await turnContext.SendActivityAsync($"You typed \"{text}\"");
            }
        }
        else
        {
            await turnContext.SendActivityAsync($"{turnContext.Activity.Type} event detected");
        }
    }
}

And for this to work, we just need to register the above class in ConfigureServices with:

services.AddTransient<IBot, ProactiveBot>();

Takeaways

To summarize, in this post we’ve learned:

  • That to send a proactive message we just need the adapter and the conversation reference, not really the bot instance.
  • That it’s possible to use a standard IBot with the Web API paradigm, just as when using the standard Bot Builder v4 paradigm.
  • To start a background thread that runs in the background.

I Hope you’ve found this post interesting and useful. Follow me on Twitter for more posts.

You are also welcomed to leave a comment or ask a question in the comments section below.

Happy coding!

Resources