mirror of
https://github.com/lukaszraczylo/go-telegram.git
synced 2026-06-05 22:43:59 +00:00
ac7cae8fa7
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
177 lines
4.9 KiB
Go
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
|
|
}
|