147 lines
3.3 KiB
Go
147 lines
3.3 KiB
Go
package platform
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"sort"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// LoginState is deliberately small: the web UI maps it to pending/scanned/
|
|
// solved without knowing platform-specific numeric QR codes.
|
|
type LoginState string
|
|
|
|
const (
|
|
LoginPending LoginState = "pending"
|
|
LoginScanned LoginState = "scanned"
|
|
LoginConfirmed LoginState = "confirmed"
|
|
)
|
|
|
|
type LoginSession struct {
|
|
Token string
|
|
URL string
|
|
}
|
|
|
|
type LoginPoll struct {
|
|
State LoginState
|
|
Cookies string
|
|
Prompt string
|
|
}
|
|
|
|
type PublishInput struct {
|
|
WorkspaceID uint64
|
|
AccountID uint64
|
|
TaskID uint64
|
|
Title string
|
|
Content string
|
|
Tags []string
|
|
MaterialURLs []string
|
|
CategoryID int
|
|
ScheduleAt *time.Time
|
|
}
|
|
|
|
type PublishResult struct {
|
|
URL string
|
|
Receipt string
|
|
}
|
|
|
|
type ErrorCategory string
|
|
|
|
const (
|
|
AuthRequired ErrorCategory = "auth_required"
|
|
ChallengeRequired ErrorCategory = "challenge_required"
|
|
Validation ErrorCategory = "validation"
|
|
RateLimited ErrorCategory = "rate_limited"
|
|
Network ErrorCategory = "network"
|
|
PlatformChanged ErrorCategory = "platform_changed"
|
|
Unknown ErrorCategory = "unknown"
|
|
)
|
|
|
|
// Error is safe to show in task history: it carries a category and excludes
|
|
// cookies, tokens and raw response bodies by construction.
|
|
type Error struct {
|
|
Category ErrorCategory
|
|
Message string
|
|
Err error
|
|
}
|
|
|
|
func (e *Error) Error() string {
|
|
if e == nil {
|
|
return ""
|
|
}
|
|
if e.Message != "" {
|
|
return fmt.Sprintf("%s: %s", e.Category, e.Message)
|
|
}
|
|
return string(e.Category)
|
|
}
|
|
|
|
func (e *Error) Unwrap() error { return e.Err }
|
|
|
|
func CategoryOf(err error) ErrorCategory {
|
|
var pe *Error
|
|
if errors.As(err, &pe) && pe.Category != "" {
|
|
return pe.Category
|
|
}
|
|
return Unknown
|
|
}
|
|
|
|
// Adapter is the server-side boundary for a platform. Implementations own
|
|
// cookies/profile details; API handlers only persist challenge state.
|
|
type Adapter interface {
|
|
Platform() string
|
|
BeginLogin(context.Context) (LoginSession, error)
|
|
PollLogin(context.Context, string) (LoginPoll, error)
|
|
SaveCredentials(workspaceID, accountID uint64, cookies string) error
|
|
Publish(context.Context, PublishInput) (PublishResult, error)
|
|
}
|
|
|
|
// CredentialChecker is optional so a platform can expose a cheap login health
|
|
// probe without forcing every adapter to implement a platform-specific API.
|
|
type CredentialChecker interface {
|
|
CheckCredentials(context.Context, uint64, uint64) error
|
|
}
|
|
|
|
type Registry struct {
|
|
mu sync.RWMutex
|
|
adapters map[string]Adapter
|
|
}
|
|
|
|
func NewRegistry() *Registry { return &Registry{adapters: make(map[string]Adapter)} }
|
|
|
|
func (r *Registry) Register(adapter Adapter) error {
|
|
if r == nil || adapter == nil || adapter.Platform() == "" {
|
|
return errors.New("invalid platform adapter")
|
|
}
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
r.adapters[adapter.Platform()] = adapter
|
|
return nil
|
|
}
|
|
|
|
func (r *Registry) Get(name string) Adapter {
|
|
if r == nil {
|
|
return nil
|
|
}
|
|
r.mu.RLock()
|
|
defer r.mu.RUnlock()
|
|
return r.adapters[name]
|
|
}
|
|
|
|
func (r *Registry) Has(name string) bool { return r.Get(name) != nil }
|
|
|
|
func (r *Registry) Names() []string {
|
|
if r == nil {
|
|
return nil
|
|
}
|
|
r.mu.RLock()
|
|
defer r.mu.RUnlock()
|
|
names := make([]string, 0, len(r.adapters))
|
|
for name := range r.adapters {
|
|
names = append(names, name)
|
|
}
|
|
sort.Strings(names)
|
|
return names
|
|
}
|