// Package fabric provides an HTTP client for posting system messages // to a Fabric Guild's announce-type channel. // // Auth model: Dialectic backend holds a system api key in // FABRIC_SYSTEM_API_KEY env. It POSTs to // `/api/channels//messages` with both: // - `Authorization: Bearer ` (the // "user" that posts; needs to be a Fabric Center user that owns // the channel) // - `x-fabric-system-key: ` (the new header added // in Phase 1 that gates announce-channel POSTs) // // Phase 2D ships this as an OPTIONAL coupling: if any of the three // config values is unset, the announcer is a no-op (logs the would-be // post and returns nil). This lets the orchestrator pipeline land + run // in environments where the Fabric coupling isn't configured yet. package fabric import ( "bytes" "context" "encoding/json" "fmt" "io" "log" "net/http" "time" ) type AnnounceConfig struct { GuildBaseURL string // e.g. https://fabric-api.hangman-lab.top ChannelID string // the announce-type channel uuid SystemAPIKey string // x-fabric-system-key value BotBearerToken string // Authorization: Bearer — Fabric user posting; same // user is the "channel author" the backend records RequestTimeout time.Duration } type Announcer struct { cfg AnnounceConfig client *http.Client } func NewAnnouncer(cfg AnnounceConfig) *Announcer { if cfg.RequestTimeout <= 0 { cfg.RequestTimeout = 5 * time.Second } return &Announcer{ cfg: cfg, client: &http.Client{Timeout: cfg.RequestTimeout}, } } // Enabled returns true iff every required Announcer config field is set. // Orchestrator checks this before each broadcast and silently skips when // false (with a one-time startup log). func (a *Announcer) Enabled() bool { return a.cfg.GuildBaseURL != "" && a.cfg.ChannelID != "" && a.cfg.SystemAPIKey != "" && a.cfg.BotBearerToken != "" } // PostTopicAnnouncement posts a one-line system message describing a // new topic that's ready for signup. Best-effort: log + nil on transport // errors so a Fabric outage doesn't block topic transitions. func (a *Announcer) PostTopicAnnouncement(ctx context.Context, topicID, title, summary string, signupOpen, signupClose, debateStart, debateEnd time.Time, verdictSchemaID string) error { if !a.Enabled() { log.Printf("announce: skipped (fabric coupling not configured) topic=%s", topicID) return nil } body := map[string]any{ "content": formatAnnouncement(topicID, title, summary, signupOpen, signupClose, debateStart, debateEnd, verdictSchemaID), } raw, _ := json.Marshal(body) url := fmt.Sprintf("%s/api/channels/%s/messages", trimRightSlash(a.cfg.GuildBaseURL), a.cfg.ChannelID) req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(raw)) if err != nil { return err } req.Header.Set("content-type", "application/json") req.Header.Set("authorization", "Bearer "+a.cfg.BotBearerToken) req.Header.Set("x-fabric-system-key", a.cfg.SystemAPIKey) resp, err := a.client.Do(req) if err != nil { log.Printf("announce: POST %s failed: %v", url, err) return err } defer resp.Body.Close() if resp.StatusCode >= 300 { b, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) log.Printf("announce: POST %s -> %d body=%s", url, resp.StatusCode, string(b)) return fmt.Errorf("announce post: status %d", resp.StatusCode) } return nil } func formatAnnouncement(id, title, summary string, signupOpen, signupClose, debateStart, debateEnd time.Time, schema string) string { const layout = "2006-01-02 15:04 UTC" return fmt.Sprintf( "🆕 **Debate signup open** [%s]\n\n**%s**\n%s\n\n"+ "• Signup: %s → %s\n"+ "• Debate: %s → %s\n"+ "• Verdict schema: `%s`\n\n"+ "To volunteer, use the `dialectic_signup` tool with this topic_id and your willing camp(s) — pro / con / judge. "+ "You must have an `on_call` slot covering the debate window.", id, title, summary, signupOpen.UTC().Format(layout), signupClose.UTC().Format(layout), debateStart.UTC().Format(layout), debateEnd.UTC().Format(layout), schema, ) } func trimRightSlash(s string) string { for len(s) > 0 && s[len(s)-1] == '/' { s = s[:len(s)-1] } return s }