actions

package
v0.5.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Nov 12, 2026 License: AGPL-3.0 Imports: 22 Imported by: 0

Documentation

Overview

Package actions (piece ⑥) decides what every reminder says and which buttons it carries, and runs the multi-step flows behind those buttons. It knows nothing about Telegram or Slack: channel adapters only render Message values and pass button data back (ADR 0003).

Index

Constants

View Source
const (
	EventReminder = "reminder" // a charge is coming
	EventDecision = "decision" // the last day to cancel is near: keep or cancel?
	EventConfirm  = "confirm"  // the due date passed: was it charged?
	EventFailed   = "failed"   // the charge failed
	EventPaid     = "paid"
	EventSkipped  = "skipped"
	EventTest     = "test" // a test message sent from Settings
	// Reminders that end in an answer.
	EventDuplicate    = "duplicate"     // the same service paid more than once
	EventCardExpiring = "card_expiring" // a card that pays subscriptions is about to expire
)

Event kinds: what a message is telling the member.

View Source
const (

	// RouteHome is the route of the home screen.
	RouteHome = appPrefix + "|" + scrHome
)

The app inside a message (ADR 0019): one message per chat that navigates between screens by editing itself and stays live as data changes.

Routes double as callback data: "a|<screen>|<arg>…". View routes are stored on the message so any change can redraw it; confirmation and mid-flow screens store "" and are left alone until the user moves on.

View Source
const SurfacePrefix = "@"

SurfacePrefix starts the message id of an app surface that is not a chat message, such as Slack's Home tab ("@home"). A surface is an app message of its chat (same flows, same navigation, kept live through Channel.Edit) that AppSent never retires and that retires nothing itself.

Variables

View Source
var Commands = []struct{ Command, Description string }{
	{"home", "Open Exhale: spend, next charges, everything"},
	{"subs", "Your subscriptions"},
	{"upcoming", "Charges in the next 30 days"},
	{"summary", "Spend per month, category and payment method"},
	{"add", "Add a subscription, step by step"},
	{"checkup", "What deserves a look: trials, deadlines, price rises"},
	{"methods", "Cards and accounts that pay"},
	{"settings", "Reminder time, payday, currency, language"},
}

Commands lists the app commands for a channel's command menu.

View Source
var ErrGone = errors.New("destination gone")

ErrGone is returned by Channel.Send when the destination no longer exists (a browser that dropped its push subscription). The channel has already removed it; Deliver skips it instead of retrying.

View Source
var PushVerbs = []string{vPaid, vSnooze}

PushVerbs are the button verbs a push notification may offer, in order. Browsers show at most two.

"Remind me tomorrow" is the snooze button with its arguments (one day, the message's kind); its label comes from the message's button.

Functions

func AlreadyLinkedText

func AlreadyLinkedText(locale string) string

AlreadyLinkedText says the chat already belongs to a member.

func ButtonVerb

func ButtonVerb(data string) (verb, chargeID string)

ButtonVerb splits a button's data into its verb and charge id. Arguments after the charge id stay with the verb, joined by ':' ("snz:1:r"), so a token that covers one verb also covers its arguments.

func ChangedByText

func ChangedByText(locale, who string) string

ChangedByText is the footer of a message someone else changed.

func CodeExpiredText

func CodeExpiredText(locale string) string

CodeExpiredText explains that a link code no longer works.

func CommandDescription

func CommandDescription(locale, description string) string

CommandDescription returns the description of a command in a language.

func CommandRoute

func CommandRoute(cmd string) (r string, ok bool)

CommandRoute maps a chat command (e.g. "/subs") to the app screen it opens. ok is false for commands that are not app screens.

func ErrorText

func ErrorText(locale string, err error) string

ErrorText is what a failure says to a person, in their language: the sentence of a domain.Error, or the error's own text without the sentinel in front.

func FailedText

func FailedText(locale string) string

FailedText is the generic "it did not work" answer.

func FileTooLargeText

func FileTooLargeText(locale string) string

FileTooLargeText answers a file over the import limit.

func HelpText

func HelpText(locale string) string

HelpText lists the commands.

func InviteExpiredText

func InviteExpiredText(locale string) string

InviteExpiredText says an invite cannot be used.

func Language

func Language(code string) string

Language picks the language of a message for someone Exhale does not know yet (an unlinked chat) from the code the chat platform reports, e.g. "es-ES": a registered language, else English.

func LinkedText

func LinkedText(locale string) string

LinkedText confirms that a chat was linked.

func OpenFailedText

func OpenFailedText(locale string) string

OpenFailedText says the app could not be opened.

func PrivateBotText

func PrivateBotText(locale string) string

PrivateBotText is what an unlinked chat hears.

func Say

func Say(locale string, t domain.Text) string

Say renders t in the given language.

func SlackAlreadyLinkedText

func SlackAlreadyLinkedText(locale string) string

SlackAlreadyLinkedText says the account already belongs to a member.

func SlackBadCodeText

func SlackBadCodeText(locale string) string

SlackBadCodeText explains that a link code no longer works.

func SlackHelpText

func SlackHelpText(locale string) string

SlackHelpText lists what can be typed in Slack.

func SlackLinkFirstText

func SlackLinkFirstText(locale string) string

SlackLinkFirstText answers a button pressed by someone not linked yet.

func SlackLinkedText

func SlackLinkedText(locale string) string

SlackLinkedText confirms that a Slack account was linked.

func SlackNotLinkedText

func SlackNotLinkedText(locale string) string

SlackNotLinkedText is what an unlinked Slack account hears.

func SlackOpenInDMText

func SlackOpenInDMText(locale string) string

SlackOpenInDMText says where the app was opened.

func SlackTypeLinkText

func SlackTypeLinkText(locale string) string

SlackTypeLinkText is the hint under the Home tab of an unlinked account.

func SnoozeData

func SnoozeData(chargeID, kind string, days int) string

SnoozeData is the button data that snoozes the message of kind (an outbox kind) about chargeID for days.

func WelcomeText

func WelcomeText(locale, household, name string) string

WelcomeText greets someone who joined from an invite.

Types

type Assistant

type Assistant interface {
	Enabled(ctx context.Context) bool
	AskStream(ctx context.Context, actor app.Actor, conv, text string, progress assistant.Progress) (assistant.Turn, error)
	Decide(ctx context.Context, actor app.Actor, id string, accept bool) (assistant.Outcome, error)
	Undo(ctx context.Context, actor app.Actor, id string) (assistant.Outcome, error)
}

Assistant is the model behind free text. *assistant.Assistant implements it; an Engine without one answers free text with the channel's help.

type Button

type Button struct {
	Label string
	Data  string
	URL   string
}

Button is an action attached to a message. Data is opaque to channels and at most 64 bytes, Telegram's limit for callback data. A button with a URL opens that page instead of running an action.

func PushButtons

func PushButtons(m Message) []Button

PushButtons returns the buttons of a message that a push notification offers: the first button of each verb in PushVerbs, about a charge and with no extra arguments (a token covers one charge and one verb).

type Channel

type Channel interface {
	Name() string
	Send(ctx context.Context, chatID string, m Message) (messageID string, err error)
	Edit(ctx context.Context, chatID, messageID string, m Message) error
}

Channel delivers messages to one chat platform. Channels that only notify and cannot edit what they sent (email, ntfy, webhooks) return an empty message id from Send; their messages are not tracked or re-edited.

type ChatRef

type ChatRef struct {
	Channel   string
	ChatID    string
	MessageID string
}

ChatRef identifies a conversation on a channel and, for button presses, the message the button belongs to.

type Engine

type Engine struct {
	// contains filtered or unexported fields
}

Engine renders reminders, runs button flows and keeps sent messages in sync with the data.

func New

func New(svc *app.Service, log *slog.Logger, channels ...Channel) *Engine

New returns an Engine delivering through the given channels.

func (*Engine) AppSent

func (e *Engine) AppSent(ctx context.Context, actor app.Actor, chat ChatRef, messageID, r string) ([]string, error)

AppSent makes messageID the live app message of the chat. Earlier app messages are retired (their buttons removed) and their ids returned, so the adapter can unpin them.

func (*Engine) AppSurface

func (e *Engine) AppSurface(ctx context.Context, actor app.Actor, chat ChatRef, messageID, r string) error

AppSurface records that the surface messageID of the chat shows route r. The adapter calls it each time it draws the surface from scratch.

func (*Engine) Ask

func (e *Engine) Ask(ctx context.Context, actor app.Actor, chat ChatRef, text string) (msgs []Message, ok bool)

Ask answers free text typed in a chat. It returns the messages to send: the answer, then one message per prepared change with its buttons. ok is false when the assistant is off; the channel then shows its help.

func (*Engine) AskLive

func (e *Engine) AskLive(ctx context.Context, actor app.Actor, chat ChatRef, text string, ch Channel, every time.Duration) (ok bool)

AskLive answers free text in a chat whose messages can be edited: the answer appears in one message as it is written, edited at most once per every (chat platforms limit edits: Telegram allows about one message a second per chat and twenty a minute in a group), and that message then becomes the finished answer. The cards follow as their own messages. ok is false when the assistant is off.

func (*Engine) AskStream

func (e *Engine) AskStream(ctx context.Context, actor app.Actor, chat ChatRef, text string, progress func(Message)) (msgs []Message, ok bool)

AskStream is Ask, telling progress the answer as it is written (see AskLive). The cards come only in the messages returned at the end.

func (*Engine) CanAsk

func (e *Engine) CanAsk(ctx context.Context) bool

CanAsk reports whether free text will be answered by the assistant: it is wired, configured and switched on.

func (*Engine) Command

func (e *Engine) Command(ctx context.Context, actor app.Actor, chat ChatRef, cmd string) (Reply, bool)

Command starts a chat flow for a command that is not a screen (currently /add). ok is false for other commands.

func (*Engine) DefaultLocale

func (e *Engine) DefaultLocale(ctx context.Context) string

DefaultLocale returns the language of this instance's household, for people Exhale does not know yet (an unlinked chat).

func (*Engine) Deliver

func (e *Engine) Deliver(ctx context.Context, item app.OutboxItem) error

Deliver sends an outbox item to every chat the member has linked. It implements the scheduler's delivery step.

func (*Engine) Handle

func (e *Engine) Handle(ctx context.Context, actor app.Actor, chat ChatRef, raw string) (Reply, error)

Handle runs the action behind a button: app navigation (ADR 0019) or a charge action. Charge actions pressed inside an app message stay inside the app.

func (*Engine) HandlePush

func (e *Engine) HandlePush(ctx context.Context, memberID, device, verb, chargeID string) (PushResult, error)

HandlePush runs a notification button for a member, through the same use case as the chat button with that verb. device is the channel link the notification went to.

func (*Engine) HandleText

func (e *Engine) HandleText(ctx context.Context, actor app.Actor, chat ChatRef, text string) (Reply, bool, error)

HandleText handles a typed message that answers a pending question (for example a new amount). ok is false if no question was pending.

func (*Engine) ImportFile

func (e *Engine) ImportFile(ctx context.Context, actor app.Actor, chat ChatRef, data []byte) Message

ImportFile reads a file sent to a chat and returns the review to show.

func (*Engine) Locale

func (e *Engine) Locale(ctx context.Context, actor app.Actor) string

Locale returns the language to speak to the actor: their own, else the household's.

func (*Engine) OpenApp

func (e *Engine) OpenApp(ctx context.Context, actor app.Actor, r string) (Message, error)

OpenApp renders the screen at route for a new app message. After sending it, the channel adapter calls AppSent.

func (*Engine) Render

func (e *Engine) Render(ctx context.Context, actor app.Actor, kind, chargeID string) (Message, error)

Render returns the message for a charge.

func (*Engine) SetAssistant

func (e *Engine) SetAssistant(a Assistant)

SetAssistant wires the assistant. Without it free text gets the help.

func (*Engine) SetPublicURL

func (e *Engine) SetPublicURL(u string)

SetPublicURL sets the address of the web, for links from chat to it.

func (*Engine) Sync

func (e *Engine) Sync(ctx context.Context) error

Sync re-edits messages already sent whenever their data changes, from any source: web, another chat, an AI agent or the scheduler (bus ⑫). Reminder messages follow their charge; live app messages redraw the screen they show (ADR 0019).

type Event

type Event struct {
	Kind           string
	SubscriptionID string
	Subscription   string
	ChargeID       string
	Amount         string // decimal, e.g. "12.99"
	Currency       string // ISO 4217
	DueDate        string // YYYY-MM-DD
	DaysUntil      int    // negative when overdue
	PaymentMethod  string
	Category       string
	// Path is the web page that shows it, e.g. /subscriptions/sub_…
	Path string
}

Event is the data behind a message, for channels that cannot show a screen with buttons: a webhook sends it as JSON, an email builds its subject from it, and all of them link to Path in the web. Messages that are not about a charge leave the fields they do not have empty.

type FileReader

type FileReader interface {
	ReadImport(ctx context.Context, actor app.Actor, data []byte) (importer.Result, error)
	SaveReview(ctx context.Context, actor app.Actor, r assistant.Review) error
	LoadReview(ctx context.Context, actor app.Actor) (assistant.Review, error)
	DropReview(ctx context.Context, actor app.Actor) error
}

FileReader reads an uploaded file into a review list and keeps the review while the person decides. *assistant.Assistant implements it.

type Message

type Message struct {
	Title    string
	Lines    []string
	Sections []Section
	Rows     [][]Button
	// Footer is shown in a muted style (e.g. "Moved by Claude · Undo").
	Footer string
	// Event is the data behind the message, for channels that only notify
	// (email, ntfy, webhooks). Nil for screens that are not about a charge.
	Event *Event
}

Message is a channel-agnostic screen: a title, detail lines, sections and rows of buttons. Each channel renders it at the best fidelity it offers (ADR 0019).

func SlackChannelHello

func SlackChannelHello(locale string) Message

SlackChannelHello is the note posted in the team channel when it is set.

type PushResult

type PushResult struct {
	Title   string `json:"title,omitempty" doc:"the charge as it is now, e.g. \"✅ Netflix paid\""`
	Message string `json:"message" doc:"what happened, e.g. \"Marked as paid ✅\""`
}

PushResult is what a notification shows after its button ran.

type Reply

type Reply struct {
	// Edit replaces the message the button belongs to.
	Edit *Message
	// Toast is a short notification (Telegram callback answer).
	Toast string
	// Send is a new message (e.g. a question that expects a typed answer).
	Send *Message
}

Reply is what the user sees after pressing a button or typing.

type Section

type Section struct {
	Heading string
	Lines   []string
	// Table rows are cells; channels without tables show TableText in a
	// monospace block. Cells should not contain emoji (they break alignment).
	Table [][]string
}

Section is a titled block of a screen: a heading followed by lines and an optional table.

func (Section) TableText

func (s Section) TableText() string

TableText aligns the table in columns: the first column left-aligned, the others right-aligned (amounts, counts).

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL