Skip to content
Merged
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
6 changes: 6 additions & 0 deletions docs/TECHNICAL-DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,12 @@ Behaviours settled in Phase 4:
- **A tap on the persistent menu aborts the active flow.** The router matches the menu before
delegating text to a conversation. Delegating first would swallow a label such as
`馃搳 Resumen` as flow input and, in the category flow, store it as a category name.
- **A screen that only owns buttons lets a compact expense pass through.** A conversation
returns `null` from `HandleTextAsync` when the text is not its input (the summary, the
statistics, the lists, the category breakdown, the settings). The router then drops that
state and parses the message as a compact expense, so a report or a list left on screen can
never swallow the next `35.000 verduras` and re-render itself. Only the states that wait for
a typed value (amount, description, name, date, time zone) own their text.
- **A draft that identifies a large value carries the position, not the value.** Telegram caps
callback data at 64 bytes, so the alias screen lists remove buttons by index into a list held
in the conversation payload and removes the keyword by term afterwards. No user text is
Expand Down
4 changes: 4 additions & 0 deletions docs/specs/gotchas.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ whenever a gotcha is learned or disproven.
- Callback data is capped at 64 bytes; identify a row by position or term when the id does not
fit. A menu tap outranks the active conversation, and some callbacks (Undo) arrive after the
flow is gone, handled as global callbacks.
- A conversation returns `null` from `HandleTextAsync` when free text is not its input; the
router then drops its state and parses the message as a compact expense. This is what stops a
report or a list left on screen from swallowing the next entry. Only the states that wait for
a typed value own their text.
- A confirmation is claimed once with a conditional `UPDATE`, never re-read; the callback
carries only the pending id. An empty listing must still carry the notices it was built with.

Expand Down
7 changes: 4 additions & 3 deletions src/MyBudget.Telegram/Conversations/CategoriesConversation.cs
Original file line number Diff line number Diff line change
Expand Up @@ -52,11 +52,13 @@ public Task<ConversationTurn> StartAsync(
ConversationContext context, CancellationToken cancellationToken) =>
BuildListAsync(context, cancellationToken, []);

public async Task<ConversationTurn> HandleTextAsync(
public async Task<ConversationTurn?> HandleTextAsync(
ConversationContext context, IncomingText text, CancellationToken cancellationToken)
{
var payload = CategoriesPayload.Parse(context.Conversation?.Payload);

// Only the states that wait for a typed value own the text; the list, the detail and the
// budget scope are navigated with buttons, so free text passes through them.
return context.CurrentState switch
{
AwaitingNameState => await HandleNameAsync(context, text.Text, cancellationToken),
Expand All @@ -70,8 +72,7 @@ public async Task<ConversationTurn> HandleTextAsync(
context, payload, text.Text, cancellationToken),
AwaitingBudgetAmountState => await HandleBudgetAmountAsync(
context, payload, text.Text, cancellationToken),
BudgetScopeState => BuildScopePrompt(context, payload),
_ => await BuildListAsync(context, cancellationToken, []),
_ => null,
};
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,10 @@ public Task<ConversationTurn> StartAsync(
ConversationContext context, CancellationToken cancellationToken) =>
BuildChooserAsync(context, cancellationToken);

public Task<ConversationTurn> HandleTextAsync(
// The breakdown only owns its navigation callbacks: free text passes through it.
public Task<ConversationTurn?> HandleTextAsync(
ConversationContext context, IncomingText text, CancellationToken cancellationToken) =>
BuildChooserAsync(context, cancellationToken);
Task.FromResult<ConversationTurn?>(null);

public async Task<ConversationTurn> HandleCallbackAsync(
ConversationContext context, IncomingCallback callback, CancellationToken cancellationToken)
Expand Down
44 changes: 34 additions & 10 deletions src/MyBudget.Telegram/Conversations/ConversationRouter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -56,26 +56,50 @@ private async Task<ConversationTurn> RouteTextCoreAsync(
if (ActiveConversation(context) is { } active)
{
var turn = await active.HandleTextAsync(context, new IncomingText(text), cancellationToken);
await PersistAsync(context, active.Name, turn, cancellationToken);
return turn;

if (turn is not null)
{
await PersistAsync(context, active.Name, turn, cancellationToken);
return turn;
}

// A screen that only owns callbacks (a report, a list, the settings) returns null for
// free text: drop its state and fall through so the message is not swallowed.
await store.ClearAsync(context.User.Id, cancellationToken);
}

// Free text with nothing active is a compact entry: "35.000 verduras" becomes the same
// confirmation. Text that carries no amount is not an entry and falls through to help.
var compact = compactParser.Parse(text, context.User.Currency);
if (compact.Outcome != CompactExpenseOutcome.Unparsed
&& Find(ExpenseConversation.ConversationName) is IExpenseEntry entry)
if (await TryStartCompactAsync(context, text, cancellationToken) is { } entryTurn)
{
await store.ClearAsync(context.User.Id, cancellationToken);
var fresh = context with { Conversation = null };
var turn = await entry.StartFromCompactAsync(fresh, compact, cancellationToken);
await PersistAsync(fresh, ExpenseConversation.ConversationName, turn, cancellationToken);
return turn;
return entryTurn;
}

return Finished(HelpTurn(context));
}

/// <summary>
/// Starts the expense flow from a free-text compact entry. Returns <c>null</c> when the text
/// carries no amount, so the caller falls through to the active flow or to help.
/// </summary>
private async Task<ConversationTurn?> TryStartCompactAsync(
ConversationContext context, string text, CancellationToken cancellationToken)
{
var compact = compactParser.Parse(text, context.User.Currency);

if (compact.Outcome == CompactExpenseOutcome.Unparsed
|| Find(ExpenseConversation.ConversationName) is not IExpenseEntry entry)
{
return null;
}

await store.ClearAsync(context.User.Id, cancellationToken);
var fresh = context with { Conversation = null };
var turn = await entry.StartFromCompactAsync(fresh, compact, cancellationToken);
await PersistAsync(fresh, ExpenseConversation.ConversationName, turn, cancellationToken);
return turn;
}

public async Task<ConversationTurn> RouteCallbackAsync(
ConversationContext context, IncomingCallback callback, CancellationToken cancellationToken) =>
await ResolveHandoffAsync(
Expand Down
2 changes: 1 addition & 1 deletion src/MyBudget.Telegram/Conversations/ExpenseConversation.cs
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ public async Task<ConversationTurn> StartFromCompactAsync(
}
}

public async Task<ConversationTurn> HandleTextAsync(
public async Task<ConversationTurn?> HandleTextAsync(
ConversationContext context, IncomingText text, CancellationToken cancellationToken)
{
var payload = ExpensePayload.Parse(context.Conversation?.Payload);
Expand Down
11 changes: 6 additions & 5 deletions src/MyBudget.Telegram/Conversations/ExpensesConversation.cs
Original file line number Diff line number Diff line change
Expand Up @@ -77,17 +77,18 @@ public Task<ConversationTurn> StartWithAsync(
? BuildDetailAsync(context, expenseId, [], cancellationToken)
: StartAsync(context, cancellationToken);

public Task<ConversationTurn> HandleTextAsync(
public async Task<ConversationTurn?> HandleTextAsync(
ConversationContext context, IncomingText text, CancellationToken cancellationToken)
{
var payload = ExpensesPayload.Parse(context.Conversation?.Payload);

// Only the edit states wait for typed values; the list and the detail are button-driven.
return context.CurrentState switch
{
EditAmountState => HandleEditAmountAsync(context, payload, text.Text, cancellationToken),
EditDescriptionState => HandleEditDescriptionAsync(context, payload, text.Text, cancellationToken),
EditDateState => HandleEditDateAsync(context, payload, text.Text, cancellationToken),
_ => BuildListAsync(context, [], cancellationToken),
EditAmountState => await HandleEditAmountAsync(context, payload, text.Text, cancellationToken),
EditDescriptionState => await HandleEditDescriptionAsync(context, payload, text.Text, cancellationToken),
EditDateState => await HandleEditDateAsync(context, payload, text.Text, cancellationToken),
_ => null,
};
}

Expand Down
8 changes: 7 additions & 1 deletion src/MyBudget.Telegram/Conversations/IConversation.cs
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,12 @@ public interface IConversation

Task<ConversationTurn> StartAsync(ConversationContext context, CancellationToken cancellationToken);

Task<ConversationTurn> HandleTextAsync(
/// <summary>
/// Handles a plain message. Returns <c>null</c> when the text is not this flow's input, so a
/// screen that only owns callbacks never swallows a free-text entry; the router then falls
/// through to the compact-expense parsing.
/// </summary>
Task<ConversationTurn?> HandleTextAsync(
ConversationContext context, IncomingText text, CancellationToken cancellationToken);

Task<ConversationTurn> HandleCallbackAsync(
Expand All @@ -83,3 +88,4 @@ internal interface IHandoffConversation
Task<ConversationTurn> StartWithAsync(
ConversationContext context, string payload, CancellationToken cancellationToken);
}

5 changes: 3 additions & 2 deletions src/MyBudget.Telegram/Conversations/RecurringConversation.cs
Original file line number Diff line number Diff line change
Expand Up @@ -56,17 +56,18 @@ public Task<ConversationTurn> StartAsync(
ConversationContext context, CancellationToken cancellationToken) =>
ListTurnAsync(context, [], cancellationToken);

public async Task<ConversationTurn> HandleTextAsync(
public async Task<ConversationTurn?> HandleTextAsync(
ConversationContext context, IncomingText text, CancellationToken cancellationToken)
{
var payload = RecurringPayload.Parse(context.Conversation?.Payload);

// Only the creation states wait for typed values; the list and the detail are button-driven.
return context.CurrentState switch
{
AmountState => HandleAmount(context, payload, text.Text),
DescriptionState => await HandleDescriptionAsync(context, payload, text.Text, cancellationToken),
DayState => HandleDay(context, payload, text.Text),
_ => await ListTurnAsync(context, [], cancellationToken),
_ => null,
};
}

Expand Down
5 changes: 3 additions & 2 deletions src/MyBudget.Telegram/Conversations/SettingsConversation.cs
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,10 @@ public Task<ConversationTurn> StartAsync(
ConversationContext context, CancellationToken cancellationToken) =>
Task.FromResult(Build(context));

public Task<ConversationTurn> HandleTextAsync(
// The settings screen only owns its toggle callbacks: free text passes through it.
public Task<ConversationTurn?> HandleTextAsync(
ConversationContext context, IncomingText text, CancellationToken cancellationToken) =>
Task.FromResult(Build(context));
Task.FromResult<ConversationTurn?>(null);

public async Task<ConversationTurn> HandleCallbackAsync(
ConversationContext context, IncomingCallback callback, CancellationToken cancellationToken)
Expand Down
10 changes: 5 additions & 5 deletions src/MyBudget.Telegram/Conversations/StartConversation.cs
Original file line number Diff line number Diff line change
Expand Up @@ -43,32 +43,32 @@ public Task<ConversationTurn> StartAsync(ConversationContext context, Cancellati
return Task.FromResult(turn);
}

public Task<ConversationTurn> HandleTextAsync(
public async Task<ConversationTurn?> HandleTextAsync(
ConversationContext context, IncomingText text, CancellationToken cancellationToken)
{
var language = context.Language;

if (context.CurrentState != AwaitingCustomTimezone)
{
return Task.FromResult(TimeZonePrompt(language, AwaitingTimezone));
return TimeZonePrompt(language, AwaitingTimezone);
}

var candidate = text.Text.Trim();

if (TimeZoneInfo.TryFindSystemTimeZoneById(candidate, out _))
{
return ConfirmAsync(context, candidate, cancellationToken);
return await ConfirmAsync(context, candidate, cancellationToken);
}

return Task.FromResult(new ConversationTurn(
return new ConversationTurn(
[
BotResponse.Message(messages.Get(language, MessageKeys.OnboardingTimezoneCustomInvalid)),
BotResponse.Message(messages.Get(language, MessageKeys.OnboardingTimezoneCustomPrompt)),
])
{
NextState = AwaitingCustomTimezone,
NextPayload = "{}",
});
};
}

public Task<ConversationTurn> HandleCallbackAsync(
Expand Down
5 changes: 3 additions & 2 deletions src/MyBudget.Telegram/Conversations/StatisticsConversation.cs
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,10 @@ public Task<ConversationTurn> StartAsync(
ConversationContext context, CancellationToken cancellationToken) =>
BuildAsync(context, new ReportingPayload(), cancellationToken);

public Task<ConversationTurn> HandleTextAsync(
// The report only owns its navigation callbacks: free text passes through it.
public Task<ConversationTurn?> HandleTextAsync(
ConversationContext context, IncomingText text, CancellationToken cancellationToken) =>
BuildAsync(context, ReportingPayload.Parse(context.Conversation?.Payload), cancellationToken);
Task.FromResult<ConversationTurn?>(null);

public async Task<ConversationTurn> HandleCallbackAsync(
ConversationContext context, IncomingCallback callback, CancellationToken cancellationToken)
Expand Down
5 changes: 3 additions & 2 deletions src/MyBudget.Telegram/Conversations/SummaryConversation.cs
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,10 @@ public Task<ConversationTurn> StartAsync(
ConversationContext context, CancellationToken cancellationToken) =>
BuildAsync(context, new ReportingPayload(), cancellationToken);

public Task<ConversationTurn> HandleTextAsync(
// The report only owns its navigation callbacks: free text passes through it.
public Task<ConversationTurn?> HandleTextAsync(
ConversationContext context, IncomingText text, CancellationToken cancellationToken) =>
BuildAsync(context, ReportingPayload.Parse(context.Conversation?.Payload), cancellationToken);
Task.FromResult<ConversationTurn?>(null);

public Task<ConversationTurn> HandleCallbackAsync(
ConversationContext context, IncomingCallback callback, CancellationToken cancellationToken)
Expand Down
27 changes: 27 additions & 0 deletions tests/MyBudget.Telegram.Tests/ConversationRouterTests.cs
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
using FluentAssertions;
using MyBudget.Application.Abstractions.Telegram;
using MyBudget.Application.Localization;
using MyBudget.Domain.Categories;
using MyBudget.Telegram.Conversations;
using MyBudget.Telegram.Presentation;
using MyBudget.Telegram.Tests.Fakes;
using NSubstitute;

namespace MyBudget.Telegram.Tests;

Expand Down Expand Up @@ -123,4 +125,29 @@ public async Task A_settings_tap_opens_the_settings_screen()
turn.Responses.Should().ContainSingle()
.Which.Text.Should().Contain(harness.Messages.Get("es", MessageKeys.SettingsTitle));
}

[Theory]
[InlineData(CategoriesConversation.ConversationName, "menu")]
[InlineData(ExpensesConversation.ConversationName, "list")]
[InlineData(RecurringConversation.ConversationName, "list")]
[InlineData(SummaryConversation.ConversationName, "summary")]
[InlineData(StatisticsConversation.ConversationName, "statistics")]
[InlineData(CategoryDetailConversation.ConversationName, "choose")]
[InlineData(SettingsConversation.ConversationName, "settings")]
public async Task A_compact_expense_passes_through_a_screen_that_only_owns_buttons(
string conversation, string state)
{
var harness = TelegramHarness.Build();
var food = new BudgetCategory(harness.User.Id, "Comida", "馃崝");
harness.CategoryService
.ListAsync(harness.User.Id, false, Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<BudgetCategory>>([food]));

var turn = await harness.Router.RouteTextAsync(
ContextFor(harness, state, conversation), "115509 carnes colanta", CancellationToken.None);

// The screen must not swallow the message: it becomes the expense flow.
turn.NextState.Should().Be("category");
harness.Conversations.SnapshotOf(harness.User.Id)!.Conversation.Should().Be("expense");
}
}
4 changes: 2 additions & 2 deletions tests/MyBudget.Telegram.Tests/StartConversationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ public async Task A_typed_time_zone_identifier_is_accepted()
CancellationToken.None);

harness.User.TimeZone.Should().Be("America/Argentina/Buenos_Aires");
turn.Completed.Should().BeTrue();
turn!.Completed.Should().BeTrue();
}

[Fact]
Expand All @@ -90,7 +90,7 @@ public async Task A_typed_value_that_is_not_a_time_zone_is_rejected()

harness.User.TimeZone.Should().Be("America/Bogota", "the default must survive an invalid answer");
harness.UnitOfWork.SaveCount.Should().Be(0);
turn.NextState.Should().Be("awaiting-custom-timezone");
turn!.NextState.Should().Be("awaiting-custom-timezone");
turn.Responses.Should().HaveCount(2);
turn.Responses[0].Text.Should().Be(harness.Messages.Get("es", MessageKeys.OnboardingTimezoneCustomInvalid));
}
Expand Down
22 changes: 22 additions & 0 deletions tests/MyBudget.Telegram.Tests/StatisticsConversationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
using MyBudget.Application.Localization;
using MyBudget.Application.Reporting;
using MyBudget.Domain.Budgets;
using MyBudget.Domain.Categories;
using MyBudget.Telegram.Conversations;
using MyBudget.Telegram.Tests.Fakes;
using NSubstitute;
Expand Down Expand Up @@ -278,4 +279,25 @@ public async Task Asking_for_a_chart_in_an_empty_month_returns_the_text_screen_i
turn.Responses[0].Photo.Should().BeNull();
turn.Responses[0].Text.Should().Contain("No hay gastos registrados en este mes.");
}

[Fact]
public async Task A_compact_expense_typed_while_the_statistics_are_on_screen_is_recorded()
{
var harness = TelegramHarness.Build();
var food = new BudgetCategory(harness.User.Id, "Comida", "馃崝");
harness.CategoryService
.ListAsync(harness.User.Id, false, Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<BudgetCategory>>([food]));

var turn = await harness.Router.RouteTextAsync(
ContextFor(harness, September, "statistics"),
"115509 carnes colanta",
CancellationToken.None);

// The statistics screen does not swallow the message: it becomes the expense flow.
turn.NextState.Should().Be("category");
harness.Conversations.SnapshotOf(harness.User.Id)!.Conversation.Should().Be("expense");
await harness.ReportService.DidNotReceive().GetStatisticsAsync(
Arg.Any<Guid>(), Arg.Any<MonthPeriod>(), Arg.Any<DateOnly>(), Arg.Any<CancellationToken>());
}
}
Loading
Loading