Files
Dialectic.Backend/internal/fabric/announce.go
hzhang a43ff2de62 feat: per-topic announce target (move guild+channel from env to topic row)
Operator decision: backend env hard-coding a single guild/channel was
wrong because (a) one Center can host many guilds and (b) one guild
can have many announce channels for different purposes. The
proposing agent now chooses where this topic's lifecycle events go,
passed as create-topic params and stored on the topic row.

Schema migration 002:
- ALTER topics ADD announce_guild_base_url VARCHAR(255) NULL,
                  announce_channel_id     VARCHAR(64)  NULL.
- Both nullable; one-of-two is rejected at POST time; both null =
  topic creator opted out of broadcasts (announcer skips with log).

handlers/topics.go: createTopicBody adds announce_guild_base_url +
announce_channel_id; validates both-or-neither.

fabric/announce.go: rewritten signature. NewAnnouncer takes only
the system api key. PostTopicAnnouncement + PostLifecycleEvent take
a Target {GuildBaseURL, ChannelID} per call. Zero-value Target -> skip.

orchestrator/ticker.go: new helper topicTarget(topic) extracts the
target from the topic row; all broadcasts route through it.

verdict.go: same per-topic target extraction at completion.

config: removed FabricGuildBaseURL, FabricAnnounceChannelID,
FabricBotBearerToken from the Config struct + env reads.
FabricSystemAPIKey env renamed to DIALECTIC_FABRIC_SYSTEM_API_KEY
to disambiguate from the Fabric backend's own
FABRIC_BACKEND_GUILD_SYSTEM_API_KEY (operator: paste the same value
into both - one says "I am the system caller", the other says "I
accept this caller as system").

FABRIC_BOT_BEARER_TOKEN is gone entirely. The upgraded Guild
ApiKeyGuard accepts x-fabric-system-key alone for announce posts;
no per-user Bearer needed. Pairs with the matching change on
nav/Fabric.Backend.Guild commit 985b06a.
2026-05-23 17:53:30 +01:00

132 lines
4.3 KiB
Go

// 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
}