Files
go-telegram/dispatch/conversation/handler.go
T
lukaszraczylo ac7cae8fa7 Initial release of go-telegram
A fully-generated, strongly-typed Go client for the Telegram Bot API.

* 176 methods + 301 types generated from Bot API v10.0
* 1408 auto-generated tests (8 scenarios per method)
* Typed unions throughout — no 'any' in the public surface
* Pluggable HTTP transport and JSON codec (default goccy/go-json)
* Built-in retry middleware honouring Telegram's retry_after
* Generic dispatcher with filters and conversation handlers
* Self-verifying codegen pipeline (regen → audit → emit → run tests)
* 14 example bots covering common patterns
2026-05-09 13:09:27 +01:00

177 lines
4.9 KiB
Go

package conversation
import (
"context"
"errors"
"github.com/lukaszraczylo/go-telegram/api"
"github.com/lukaszraczylo/go-telegram/dispatch"
)
// stateTransition is a sentinel error type carrying a state transition
// or end signal. Conversation handlers return one of these (via Next or
// End helpers below) to drive the state machine.
type stateTransition struct {
next State
end bool
}
func (e *stateTransition) Error() string {
if e.end {
return "conversation: end"
}
return "conversation: → " + string(e.next)
}
// Next signals the conversation should advance to the given state.
// Conversation handlers return Next("state_name") to transition.
func Next(s State) error {
return &stateTransition{next: s}
}
// End signals the conversation has finished and state should be cleared.
// Conversation handlers return End() to terminate.
func End() error {
return &stateTransition{end: true}
}
// Handler defines a step in the conversation. Receives the dispatch context
// and the raw update. Returns:
// - nil to stay in the current state
// - Next("state") to transition to a different state
// - End() to end the conversation
// - any other non-nil error to surface to the dispatcher (state unchanged)
type Handler func(ctx *dispatch.Context, u *api.Update) error
// Step pairs a filter with a handler for one conversation step.
type Step struct {
Filter dispatch.Filter[*api.Update]
Handler Handler
}
// Conversation is a stateful handler with entry, per-state, exit and
// fallback steps. A conversation is keyed by KeyStrategy (default
// KeyByUserAndChat) and persisted by Storage (default in-memory).
type Conversation struct {
// EntryPoints starts a new conversation when a matching filter fires
// and no conversation is already active for the key.
EntryPoints []Step
// States maps each state to the steps that handle it.
States map[State][]Step
// Exits, if any match, end the active conversation early. Useful for
// /cancel-style commands.
Exits []Step
// Fallbacks run when no state step matches the current update.
Fallbacks []Step
// Storage persists conversation state. Defaults to NewMemoryStorage.
Storage Storage
// KeyStrategy derives the persistence key. Defaults to KeyByUserAndChat.
KeyStrategy KeyStrategy
// AllowReEntry, when true, lets entry-point steps fire even while a
// conversation is already active for the key (effectively restarting it).
AllowReEntry bool
}
// Dispatch is a global middleware-shaped Handler that consumes updates
// and routes them through the conversation graph. Register via
// router.Use(conv.Dispatch).
//
// If the conversation claims an update, downstream handlers are skipped.
// If the conversation does not claim it, downstream handlers run as normal.
func (c *Conversation) Dispatch(next dispatch.Handler[*api.Update]) dispatch.Handler[*api.Update] {
if c.Storage == nil {
c.Storage = NewMemoryStorage()
}
if c.KeyStrategy == nil {
c.KeyStrategy = KeyByUserAndChat
}
return func(dctx *dispatch.Context, u *api.Update) error {
key := c.KeyStrategy(u)
if key == "" {
return next(dctx, u)
}
ctx := dctx.Ctx
current, err := c.Storage.Get(ctx, key)
if err != nil && !errors.Is(err, ErrKeyNotFound) {
return err
}
active := !errors.Is(err, ErrKeyNotFound)
// Try exits first (always allowed if conversation is active).
if active {
for _, step := range c.Exits {
if step.Filter(u) {
if err := c.runStep(ctx, dctx, u, key, step.Handler); err != nil {
return err
}
return nil
}
}
}
// Try entry points (only if no active conversation, or AllowReEntry).
if !active || c.AllowReEntry {
for _, step := range c.EntryPoints {
if step.Filter(u) {
if err := c.runStep(ctx, dctx, u, key, step.Handler); err != nil {
return err
}
return nil
}
}
}
if !active {
return next(dctx, u)
}
// Active conversation: try state steps.
for _, step := range c.States[current] {
if step.Filter(u) {
if err := c.runStep(ctx, dctx, u, key, step.Handler); err != nil {
return err
}
return nil
}
}
// Fallbacks if no state step matched.
for _, step := range c.Fallbacks {
if step.Filter(u) {
if err := c.runStep(ctx, dctx, u, key, step.Handler); err != nil {
return err
}
return nil
}
}
// Active conversation but no step matched and no fallback: swallow the
// update (do NOT pass to downstream handlers, since the user is
// mid-conversation and an unrelated handler would surprise them).
return nil
}
}
// runStep invokes the handler and applies its return-value state transition.
func (c *Conversation) runStep(ctx context.Context, dctx *dispatch.Context, u *api.Update, key string, h Handler) error {
err := h(dctx, u)
if err == nil {
return nil
}
var trans *stateTransition
if errors.As(err, &trans) {
if trans.end {
return c.Storage.Delete(ctx, key)
}
return c.Storage.Set(ctx, key, trans.next)
}
return err
}