// Package fabric provides an HTTP client for posting system messages // to a Fabric Guild's announce-type channel. // // As of Phase 3.5 the target (guildBaseUrl + channelID) is per-call // rather than backend-env: each topic stores its own announce target, // chosen by the proposing agent at create time. Only the system api // key stays in backend env (DIALECTIC_FABRIC_SYSTEM_API_KEY) since it // authorizes ANY announce POST regardless of channel. // // FABRIC_BOT_BEARER_TOKEN is gone — the upgraded Fabric.Backend.Guild // ApiKeyGuard accepts x-fabric-system-key alone (no per-user Bearer // required) for announce-channel posts. package fabric import ( "bytes" "context" "encoding/json" "fmt" "io" "log" "net/http" "time" ) type Announcer struct { systemAPIKey string requestTimeout time.Duration client *http.Client } func NewAnnouncer(systemAPIKey string) *Announcer { timeout := 5 * time.Second return &Announcer{ systemAPIKey: systemAPIKey, requestTimeout: timeout, client: &http.Client{Timeout: timeout}, } } // Enabled returns true iff the system api key is configured (without // it, no announce POST can authenticate). func (a *Announcer) Enabled() bool { return a.systemAPIKey != "" } // Target identifies one announce post destination. Both fields must // be non-empty; either empty -> Post* returns a "skipped" no-op. type Target struct { GuildBaseURL string ChannelID string } // PostTopicAnnouncement: first signup-open broadcast. // PostLifecycleEvent: subsequent state transitions. // // Both best-effort: log + nil on transport / target-misconfigured // errors so the orchestrator transition isn't blocked by a Fabric // outage or an opted-out topic. func (a *Announcer) PostTopicAnnouncement(ctx context.Context, t Target, topicID, title, summary string, signupOpen, signupClose, debateStart, debateEnd time.Time, verdictSchemaID string) error { body := formatAnnouncement(topicID, title, summary, signupOpen, signupClose, debateStart, debateEnd, verdictSchemaID) return a.post(ctx, t, topicID, "signup_open", body) } func (a *Announcer) PostLifecycleEvent(ctx context.Context, t Target, topicID, title, kind, summary string) error { body := fmt.Sprintf("📣 **[%s]** %s [%s]\n%s", kind, title, topicID, summary) return a.post(ctx, t, topicID, kind, body) } func (a *Announcer) post(ctx context.Context, t Target, topicID, kind, content string) error { if !a.Enabled() { log.Printf("announce: skipped topic=%s kind=%s — DIALECTIC_FABRIC_SYSTEM_API_KEY unset", topicID, kind) return nil } if t.GuildBaseURL == "" || t.ChannelID == "" { log.Printf("announce: skipped topic=%s kind=%s — no announce target on topic (creator opted out)", topicID, kind) return nil } body, _ := json.Marshal(map[string]any{"content": content}) url := fmt.Sprintf("%s/api/channels/%s/messages", trimRightSlash(t.GuildBaseURL), t.ChannelID) req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) if err != nil { return err } req.Header.Set("content-type", "application/json") req.Header.Set("x-fabric-system-key", a.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 kind=%s -> %d body=%s", url, kind, 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 }