Files
Laniakea/drafts.go
2026-03-13 12:25:53 +03:00

293 lines
8.6 KiB
Go

// Package laniakea provides a safe, high-level interface for managing Telegram
// message drafts using the tgapi library. It allows creating, editing, and
// flushing drafts with automatic ID generation and optional bulk flushing.
//
// Drafts are designed to be ephemeral, mutable buffers that can be built up
// incrementally and then sent as final messages. The package ensures safe
// state management by copying entities and isolating draft contexts.
//
// Two draft ID generation strategies are supported:
// - Random: Cryptographically secure random IDs (default). Ideal for distributed systems.
// - Linear: Monotonically increasing IDs. Useful for persistence, debugging, or recovery.
//
// Example usage:
//
// provider := laniakea.NewRandomDraftProvider(api)
//
// draft := provider.NewDraft(tgapi.ParseModeMarkdown)
// draft.SetChat(-1001234567890, 0)
// draft.Push("*Hello*").Push(" **world**!")
// err := draft.Flush() // Sends message and deletes draft
// if err != nil {
// log.Printf("Failed to send draft: %v", err)
// }
//
// // Or flush all pending drafts at once:
// err = provider.FlushAll() // Sends all drafts and clears them
//
// Note: Drafts are NOT thread-safe. Concurrent access requires external synchronization.
package laniakea
import (
"errors"
"math/rand/v2"
"sync"
"sync/atomic"
"git.nix13.pw/scuroneko/laniakea/tgapi"
)
var ErrDraftChatIDZero = errors.New("zero draft chat ID")
// draftIdGenerator defines an interface for generating unique draft IDs.
type draftIdGenerator interface {
// Next returns the next unique draft ID.
Next() uint64
}
// RandomDraftIdGenerator generates draft IDs using cryptographically secure random numbers.
// Suitable for distributed systems or when ID predictability is undesirable.
type RandomDraftIdGenerator struct{}
// Next returns a random 64-bit unsigned integer.
func (g *RandomDraftIdGenerator) Next() uint64 {
return rand.Uint64()
}
// LinearDraftIdGenerator generates draft IDs using a monotonically increasing counter.
// Useful for debugging, persistence, or when drafts must be ordered.
type LinearDraftIdGenerator struct {
lastId atomic.Uint64
}
// Next returns the next linear ID, atomically incremented.
func (g *LinearDraftIdGenerator) Next() uint64 {
return g.lastId.Add(1)
}
// DraftProvider manages a collection of Drafts and provides methods to create and
// configure them. It holds shared configuration (chat, parse mode, entities) and
// a draft ID generator.
//
// DraftProvider is NOT thread-safe. Concurrent access from multiple goroutines
// requires external synchronization.
type DraftProvider struct {
mu sync.RWMutex
api *tgapi.API
drafts map[uint64]*Draft
generator draftIdGenerator
}
// NewRandomDraftProvider creates a new DraftProvider using random draft IDs.
//
// The provider will use cryptographically secure random numbers for draft IDs.
// All drafts created via this provider will have unpredictable, unique IDs.
func NewRandomDraftProvider(api *tgapi.API) *DraftProvider {
return &DraftProvider{
api: api, generator: &RandomDraftIdGenerator{},
drafts: make(map[uint64]*Draft),
}
}
// NewLinearDraftProvider creates a new DraftProvider using linear (incrementing) draft IDs.
//
// startValue is the initial value for the counter. Use 0 for fresh start, or a known
// value to resume from persisted state.
//
// This is useful when you need to store draft IDs externally (e.g., in a database)
// and want to reconstruct drafts after restart.
func NewLinearDraftProvider(api *tgapi.API, startValue uint64) *DraftProvider {
g := &LinearDraftIdGenerator{}
g.lastId.Store(startValue)
return &DraftProvider{
api: api,
generator: g,
drafts: make(map[uint64]*Draft),
}
}
// GetDraft retrieves a draft by its ID.
//
// Returns the draft and true if found, or nil and false if not found.
func (p *DraftProvider) GetDraft(id uint64) (*Draft, bool) {
p.mu.RLock()
defer p.mu.RUnlock()
draft, ok := p.drafts[id]
return draft, ok
}
// FlushAll sends all pending drafts as final messages and clears them.
//
// If any draft fails to send, FlushAll returns the error immediately and
// leaves other drafts unflushed. This allows for retry logic or logging.
//
// After successful flush, each draft is removed from the provider and cleared.
func (p *DraftProvider) FlushAll() error {
p.mu.Lock()
drafts := make([]*Draft, 0, len(p.drafts))
for _, draft := range p.drafts {
drafts = append(drafts, draft)
}
p.drafts = make(map[uint64]*Draft)
p.mu.Unlock()
var lastErr error
for _, draft := range drafts {
if err := draft.Flush(); err != nil {
lastErr = err
break // Stop on first error to avoid partial state
}
}
return lastErr
}
// Draft represents a single message draft that can be edited and flushed.
//
// Drafts are safe to use from a single goroutine. Multiple goroutines must
// synchronize access manually.
//
// Drafts are automatically removed from the provider's map when Flush() succeeds.
type Draft struct {
api *tgapi.API
provider *DraftProvider
chatID int64
messageThreadID int
parseMode tgapi.ParseMode
entities []tgapi.MessageEntity
ID uint64
Message string
}
// NewDraft creates a new draft with the provided parse mode.
//
// The draft inherits the provider's chatID, messageThreadID, and entities.
// If parseMode is zero, the provider's default parseMode is used.
//
// Panics if chatID is zero — call SetChat() on the provider first.
func (p *DraftProvider) NewDraft(parseMode tgapi.ParseMode) *Draft {
id := p.generator.Next()
draft := &Draft{
api: p.api,
provider: p,
parseMode: parseMode,
ID: id,
Message: "",
}
p.mu.Lock()
p.drafts[id] = draft
p.mu.Unlock()
return draft
}
// SetChat overrides the draft's target chat and message thread.
//
// This is useful for sending a draft to a different chat than the provider's default.
func (d *Draft) SetChat(chatID int64, messageThreadID int) *Draft {
d.chatID = chatID
d.messageThreadID = messageThreadID
return d
}
// SetEntities replaces the draft's message entities.
//
// Entities are stored by reference. If you plan to mutate the slice later,
// pass a copy: `SetEntities(append([]tgapi.MessageEntity{}, myEntities...))`
func (d *Draft) SetEntities(entities []tgapi.MessageEntity) *Draft {
d.entities = entities
return d
}
// Push appends text to the draft and attempts to update the server-side draft.
//
// Returns an error if the Telegram API rejects the update (e.g., due to network issues).
// The draft's Message field is always updated, even if the API call fails.
//
// Use this method to build the message incrementally.
func (d *Draft) Push(text string) error {
return d.push(text)
}
// GetMessage returns the current content of the draft.
//
// Useful for inspection, logging, or validation before flushing.
func (d *Draft) GetMessage() string {
return d.Message
}
// Clear resets the draft's message content to empty string.
//
// Does not affect server-side draft — use Flush() for that.
func (d *Draft) Clear() {
d.Message = ""
}
// Delete removes the draft from its provider and clears its content.
//
// This is an internal method used by Flush(). You may call it manually if you
// want to cancel a draft without sending it.
func (d *Draft) Delete() {
if d.provider != nil {
d.provider.mu.Lock()
delete(d.provider.drafts, d.ID)
d.provider.mu.Unlock()
}
d.Clear()
}
// Flush sends the draft as a final message and clears it locally.
//
// If successful:
// - The message is sent to Telegram.
// - The draft's content is cleared.
// - The draft is removed from the provider's map.
//
// If an error occurs:
// - The message is NOT sent.
// - The draft remains in the provider and retains its content.
// - You can call Flush() again to retry.
//
// If the draft is empty, Flush() returns nil without calling the API.
func (d *Draft) Flush() error {
if d.Message == "" {
return nil
}
params := tgapi.SendMessageP{
ChatID: d.chatID,
ParseMode: d.parseMode,
Entities: d.entities,
Text: d.Message,
}
if d.messageThreadID > 0 {
params.MessageThreadID = d.messageThreadID
}
_, err := d.api.SendMessage(params)
if err == nil {
d.Delete()
}
return err
}
// push is the internal helper for Push(). It updates the server draft via SendMessageDraft.
func (d *Draft) push(text string) error {
if d.chatID == 0 {
return ErrDraftChatIDZero
}
d.Message += text
params := tgapi.SendMessageDraftP{
ChatID: d.chatID,
DraftID: d.ID,
Text: d.Message,
ParseMode: d.parseMode,
Entities: d.entities,
}
if d.messageThreadID > 0 {
params.MessageThreadID = d.messageThreadID
}
_, err := d.api.SendMessageDraft(params)
return err
}