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
97 lines
2.4 KiB
Go
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
|
|
}
|