312 lines
9.6 KiB
Go
312 lines
9.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 (
|
|
"math/rand/v2"
|
|
"sync/atomic"
|
|
|
|
"git.nix13.pw/scuroneko/laniakea/tgapi"
|
|
)
|
|
|
|
// 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 {
|
|
api *tgapi.API
|
|
drafts map[uint64]*Draft
|
|
generator draftIdGenerator
|
|
|
|
// Internal defaults — not exposed directly to users.
|
|
chatID int64
|
|
messageThreadID int
|
|
parseMode tgapi.ParseMode
|
|
entities []tgapi.MessageEntity
|
|
}
|
|
|
|
// 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),
|
|
}
|
|
}
|
|
|
|
// SetChat sets the target chat and optional message thread for all drafts created
|
|
// by this provider. Must be called before NewDraft().
|
|
//
|
|
// If not set, NewDraft() will create drafts with zero chatID, which will cause
|
|
// SendMessageDraft to fail. Use this method to avoid runtime errors.
|
|
func (p *DraftProvider) SetChat(chatID int64, messageThreadID int) *DraftProvider {
|
|
p.chatID = chatID
|
|
p.messageThreadID = messageThreadID
|
|
return p
|
|
}
|
|
|
|
// SetParseMode sets the default parse mode for all new drafts.
|
|
// Overrides the parse mode passed to NewDraft() only if not specified there.
|
|
func (p *DraftProvider) SetParseMode(mode tgapi.ParseMode) *DraftProvider {
|
|
p.parseMode = mode
|
|
return p
|
|
}
|
|
|
|
// SetEntities sets the default message entities (e.g., bold, links, mentions)
|
|
// to be copied into every new draft.
|
|
//
|
|
// Entities are shallow-copied — if you mutate the slice later, it will affect
|
|
// future drafts. For safety, pass a copy if needed.
|
|
func (p *DraftProvider) SetEntities(entities []tgapi.MessageEntity) *DraftProvider {
|
|
p.entities = entities
|
|
return p
|
|
}
|
|
|
|
// 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) {
|
|
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 {
|
|
var lastErr error
|
|
for _, draft := range p.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 {
|
|
if p.chatID == 0 {
|
|
panic("laniakea: DraftProvider.SetChat() must be called before NewDraft()")
|
|
}
|
|
|
|
id := p.generator.Next()
|
|
draft := &Draft{
|
|
api: p.api,
|
|
provider: p,
|
|
chatID: p.chatID,
|
|
messageThreadID: p.messageThreadID,
|
|
parseMode: parseMode,
|
|
entities: p.entities, // Shallow copy — caller must ensure immutability
|
|
ID: id,
|
|
Message: "",
|
|
}
|
|
p.drafts[id] = draft
|
|
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 {
|
|
delete(d.provider.drafts, d.ID)
|
|
}
|
|
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 {
|
|
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
|
|
}
|