fluid-telegram/internal/adapter/store.go
tegwick bfebca3aee Implement the R-1 adapter (FT-WP-0001 T05/T09); mark workplans blocked
Builds internal/adapter and cmd/adapter: the three contracts/r1.openapi.yaml
operations, idempotency on post_id, the reviewed_by/consent-basis refusals,
per-form (R-2/R-3) length limits, and Markdown-to-Telegram rendering, all
against a Sender interface so it's testable without a live bot token.

Every other open task in FT-WP-0001 and FT-WP-0002 needs something this repo
can't supply on its own: the human session bootstrap in FT-WP-0002 T01, the
fluid CLI from fluid-core, or a person's judgment (consent basis, editorial
review, publishing pace). Set both workplans to status: blocked and flagged
FT-WP-0002-T01 needs_human on the hub, since it's the single blocker gating
everything else.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: sonnet
Assistant-Process: 278411@bnt-lap001
Assistant-Session: 19568045-15bc-4b88-ba3d-8e95b4f9c6f1
2026-09-27 23:15:37 +02:00

97 lines
2.4 KiB
Go

package adapter
import (
"sort"
"sync"
"time"
)
// State is a publication's lifecycle, per contracts/r1.openapi.yaml's `state`
// enum on listPosts.
type State string
const (
StatePending State = "pending"
StatePublished State = "published"
StateFailed State = "failed"
StateSuperseded State = "superseded"
)
// Publication is this adapter's own record of a post_id: which Telegram
// message implements it. The evidence store records that a publication
// happened; this is the adapter's mapping, kept separately per
// docs/adapter-contract.md.
type Publication struct {
PostID string
State State
TelegramMsgID int
Body string // the rendered text or caption last sent, for edit-detection
SourceEntryID string
Variant string
PublishedAt time.Time
LastAttemptErr string
}
// Store is the adapter's idempotency mapping from post_id to what was sent.
// It is the adapter's own state, never fluid-core's.
type Store interface {
Get(postID string) (Publication, bool)
Put(Publication)
List(filter ListFilter) []Publication
}
// ListFilter narrows GET /v1/channel-posts, mirroring its query parameters.
type ListFilter struct {
State State
Variant string
SourceEntryID string
Limit int
}
type memStore struct {
mu sync.RWMutex
byID map[string]Publication
}
// NewMemStore is an in-process idempotency store. It is what one adapter
// instance needs for one channel; nothing here is shared with fluid-core or
// with another channel's adapter instance.
func NewMemStore() Store {
return &memStore{byID: map[string]Publication{}}
}
func (s *memStore) Get(postID string) (Publication, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
p, ok := s.byID[postID]
return p, ok
}
func (s *memStore) Put(p Publication) {
s.mu.Lock()
defer s.mu.Unlock()
s.byID[p.PostID] = p
}
func (s *memStore) List(f ListFilter) []Publication {
s.mu.RLock()
defer s.mu.RUnlock()
out := make([]Publication, 0, len(s.byID))
for _, p := range s.byID {
if f.State != "" && p.State != f.State {
continue
}
if f.Variant != "" && p.Variant != f.Variant {
continue
}
if f.SourceEntryID != "" && p.SourceEntryID != f.SourceEntryID {
continue
}
out = append(out, p)
}
sort.Slice(out, func(i, j int) bool { return out[i].PublishedAt.After(out[j].PublishedAt) })
if f.Limit > 0 && len(out) > f.Limit {
out = out[:f.Limit]
}
return out
}