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.
This commit is contained in:
h z
2026-05-23 17:53:30 +01:00
parent b2a0cac460
commit a43ff2de62
10 changed files with 190 additions and 161 deletions

View File

@@ -66,14 +66,24 @@ type Config struct {
OIDCIssuer string OIDCIssuer string
OIDCClientID string OIDCClientID string
// Fabric announce coupling (Phase 2D). All four required to enable; // Fabric announce coupling.
// any empty → announcer becomes a no-op (logs intent, skips post). //
// This lets the orchestrator run in environments where the Fabric // As of Phase 3.5: only the system api key stays in env. Guild base
// coupling hasn't been wired yet. // URL + announce channel ID are PER-TOPIC, supplied by the proposing
FabricGuildBaseURL string // e.g. https://fabric-api.hangman-lab.top // agent at create time and stored on the topic row. Different topics
FabricAnnounceChannelID string // can broadcast to different guilds/channels from the same backend.
FabricSystemAPIKey string // x-fabric-system-key value (env: FABRIC_SYSTEM_API_KEY) //
FabricBotBearerToken string // Authorization Bearer for the dialectic-system Fabric user // FabricSystemAPIKey: header value for x-fabric-system-key when
// POSTing to announce channels. Must match Fabric.Backend.Guild's
// FABRIC_BACKEND_GUILD_SYSTEM_API_KEY env. Empty → announcer becomes
// a no-op (logs intent, skips post — useful for dev / opted-out
// environments).
FabricSystemAPIKey string
// (removed Phase 3.5: FabricGuildBaseURL, FabricAnnounceChannelID,
// FabricBotBearerToken — guild/channel moved to per-topic config;
// bot bearer obsolete since Guild's ApiKeyGuard now accepts system
// key alone for announce posts.)
// Orchestrator tick interval. 0 / unset → default 15s. // Orchestrator tick interval. 0 / unset → default 15s.
OrchestratorTickInterval time.Duration OrchestratorTickInterval time.Duration
@@ -81,24 +91,21 @@ type Config struct {
func LoadFromEnv() (*Config, error) { func LoadFromEnv() (*Config, error) {
c := &Config{ c := &Config{
Mode: getenv("ENV_MODE", "dev"), Mode: getenv("ENV_MODE", "dev"),
HTTPAddr: getenv("HTTP_ADDR", "0.0.0.0:8090"), HTTPAddr: getenv("HTTP_ADDR", "0.0.0.0:8090"),
CORSAllowOrigins: splitCSV(getenv("CORS_ALLOW_ORIGINS", "*")), CORSAllowOrigins: splitCSV(getenv("CORS_ALLOW_ORIGINS", "*")),
DBHost: getenv("DB_HOST", "127.0.0.1"), DBHost: getenv("DB_HOST", "127.0.0.1"),
DBPort: getenv("DB_PORT", "3306"), DBPort: getenv("DB_PORT", "3306"),
DBName: getenv("DB_NAME", "dialectic"), DBName: getenv("DB_NAME", "dialectic"),
DBUser: getenv("DB_USER", "dialectic"), DBUser: getenv("DB_USER", "dialectic"),
DBPassword: os.Getenv("DB_PASSWORD"), DBPassword: os.Getenv("DB_PASSWORD"),
SystemAPIKey: os.Getenv("SYSTEM_API_KEY"), SystemAPIKey: os.Getenv("SYSTEM_API_KEY"),
AgentAPIKeyPepper: os.Getenv("AGENT_API_KEY_PEPPER"), AgentAPIKeyPepper: os.Getenv("AGENT_API_KEY_PEPPER"),
OIDCDevBypassToken: os.Getenv("OIDC_DEV_BYPASS_TOKEN"), OIDCDevBypassToken: os.Getenv("OIDC_DEV_BYPASS_TOKEN"),
DialecticAdminAPIKey: os.Getenv("DIALECTIC_ADMIN_API_KEY"), DialecticAdminAPIKey: os.Getenv("DIALECTIC_ADMIN_API_KEY"),
OIDCIssuer: os.Getenv("OIDC_ISSUER"), OIDCIssuer: os.Getenv("OIDC_ISSUER"),
OIDCClientID: os.Getenv("OIDC_CLIENT_ID"), OIDCClientID: os.Getenv("OIDC_CLIENT_ID"),
FabricGuildBaseURL: os.Getenv("FABRIC_GUILD_BASE_URL"), FabricSystemAPIKey: os.Getenv("DIALECTIC_FABRIC_SYSTEM_API_KEY"),
FabricAnnounceChannelID: os.Getenv("FABRIC_ANNOUNCE_CHANNEL_ID"),
FabricSystemAPIKey: os.Getenv("FABRIC_SYSTEM_API_KEY"),
FabricBotBearerToken: os.Getenv("FABRIC_BOT_BEARER_TOKEN"),
} }
if d := os.Getenv("ORCHESTRATOR_TICK_INTERVAL"); d != "" { if d := os.Getenv("ORCHESTRATOR_TICK_INTERVAL"); d != "" {
if parsed, err := time.ParseDuration(d); err == nil { if parsed, err := time.ParseDuration(d); err == nil {

View File

@@ -0,0 +1,10 @@
-- 002_topic_announce_target.sql — move announce-channel routing from
-- backend env to per-topic config. Topic creator picks which Fabric
-- guild + announce channel to broadcast lifecycle events to (a single
-- backend deployment can serve topics broadcasting to multiple guilds/
-- channels). Both fields are nullable; null means "do not broadcast
-- for this topic" (intentional opt-out).
ALTER TABLE topics
ADD COLUMN announce_guild_base_url VARCHAR(255) NULL AFTER cancelled_reason,
ADD COLUMN announce_channel_id VARCHAR(64) NULL AFTER announce_guild_base_url;

View File

@@ -1,19 +1,15 @@
// Package fabric provides an HTTP client for posting system messages // Package fabric provides an HTTP client for posting system messages
// to a Fabric Guild's announce-type channel. // to a Fabric Guild's announce-type channel.
// //
// Auth model: Dialectic backend holds a system api key in // As of Phase 3.5 the target (guildBaseUrl + channelID) is per-call
// FABRIC_SYSTEM_API_KEY env. It POSTs to // rather than backend-env: each topic stores its own announce target,
// `<guildBaseUrl>/api/channels/<channelId>/messages` with both: // chosen by the proposing agent at create time. Only the system api
// - `Authorization: Bearer <fabric-system-account-token>` (the // key stays in backend env (DIALECTIC_FABRIC_SYSTEM_API_KEY) since it
// "user" that posts; needs to be a Fabric Center user that owns // authorizes ANY announce POST regardless of channel.
// the channel)
// - `x-fabric-system-key: <SYSTEM_API_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 // FABRIC_BOT_BEARER_TOKEN is gone — the upgraded Fabric.Backend.Guild
// config values is unset, the announcer is a no-op (logs the would-be // ApiKeyGuard accepts x-fabric-system-key alone (no per-user Bearer
// post and returns nil). This lets the orchestrator pipeline land + run // required) for announce-channel posts.
// in environments where the Fabric coupling isn't configured yet.
package fabric package fabric
import ( import (
@@ -27,62 +23,74 @@ import (
"time" "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 <token> — Fabric user posting; same
// user is the "channel author" the backend records
RequestTimeout time.Duration
}
type Announcer struct { type Announcer struct {
cfg AnnounceConfig systemAPIKey string
client *http.Client requestTimeout time.Duration
client *http.Client
} }
func NewAnnouncer(cfg AnnounceConfig) *Announcer { func NewAnnouncer(systemAPIKey string) *Announcer {
if cfg.RequestTimeout <= 0 { timeout := 5 * time.Second
cfg.RequestTimeout = 5 * time.Second
}
return &Announcer{ return &Announcer{
cfg: cfg, systemAPIKey: systemAPIKey,
client: &http.Client{Timeout: cfg.RequestTimeout}, requestTimeout: timeout,
client: &http.Client{Timeout: timeout},
} }
} }
// Enabled returns true iff every required Announcer config field is set. // Enabled returns true iff the system api key is configured (without
// Orchestrator checks this before each broadcast and silently skips when // it, no announce POST can authenticate).
// false (with a one-time startup log). func (a *Announcer) Enabled() bool { return a.systemAPIKey != "" }
func (a *Announcer) Enabled() bool {
return a.cfg.GuildBaseURL != "" && a.cfg.ChannelID != "" && // Target identifies one announce post destination. Both fields must
a.cfg.SystemAPIKey != "" && a.cfg.BotBearerToken != "" // be non-empty; either empty -> Post* returns a "skipped" no-op.
type Target struct {
GuildBaseURL string
ChannelID string
} }
// PostTopicAnnouncement posts a one-line system message describing a // PostTopicAnnouncement: first signup-open broadcast.
// new topic that's ready for signup. Best-effort: log + nil on transport // PostLifecycleEvent: subsequent state transitions.
// errors so a Fabric outage doesn't block topic transitions. //
func (a *Announcer) PostTopicAnnouncement(ctx context.Context, topicID, title, summary string, // Both best-effort: log + nil on transport / target-misconfigured
signupOpen, signupClose, debateStart, debateEnd time.Time, verdictSchemaID string) error { // 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() { if !a.Enabled() {
log.Printf("announce: skipped (fabric coupling not configured) topic=%s", topicID) 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 return nil
} }
body := map[string]any{ body, _ := json.Marshal(map[string]any{"content": content})
"content": formatAnnouncement(topicID, title, summary, signupOpen, signupClose, debateStart, debateEnd, verdictSchemaID),
}
raw, _ := json.Marshal(body)
url := fmt.Sprintf("%s/api/channels/%s/messages", url := fmt.Sprintf("%s/api/channels/%s/messages",
trimRightSlash(a.cfg.GuildBaseURL), a.cfg.ChannelID) trimRightSlash(t.GuildBaseURL), t.ChannelID)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(raw)) req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if err != nil { if err != nil {
return err return err
} }
req.Header.Set("content-type", "application/json") req.Header.Set("content-type", "application/json")
req.Header.Set("authorization", "Bearer "+a.cfg.BotBearerToken) req.Header.Set("x-fabric-system-key", a.systemAPIKey)
req.Header.Set("x-fabric-system-key", a.cfg.SystemAPIKey)
resp, err := a.client.Do(req) resp, err := a.client.Do(req)
if err != nil { if err != nil {
@@ -92,7 +100,7 @@ func (a *Announcer) PostTopicAnnouncement(ctx context.Context, topicID, title, s
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode >= 300 { if resp.StatusCode >= 300 {
b, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) b, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
log.Printf("announce: POST %s -> %d body=%s", url, resp.StatusCode, string(b)) 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 fmt.Errorf("announce post: status %d", resp.StatusCode)
} }
return nil return nil
@@ -115,47 +123,6 @@ func formatAnnouncement(id, title, summary string,
) )
} }
// PostLifecycleEvent broadcasts a non-signup state change to the
// announce channel. The agents' Fabric inbound + busy-discard
// (Phase 1) handles per-agent wakeup gating — no per-agent fanout
// needed from this side. Sole purpose is to let agents see "your
// debate moved to X" without polling.
//
// Currently called for: signup_closed (with allocated camps),
// cancelled, debating-start, completed. Format is plain text with the
// topic_id so agents' workflows can parse and route.
func (a *Announcer) PostLifecycleEvent(ctx context.Context, topicID, title, kind, summary string) error {
if !a.Enabled() {
log.Printf("announce: lifecycle skipped (fabric coupling not configured) topic=%s kind=%s", topicID, kind)
return nil
}
body := map[string]any{
"content": fmt.Sprintf("📣 **[%s]** %s [%s]\n%s", kind, title, topicID, summary),
}
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: lifecycle 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: lifecycle POST %s -> %d body=%s", url, resp.StatusCode, string(b))
return fmt.Errorf("lifecycle post: status %d", resp.StatusCode)
}
return nil
}
func trimRightSlash(s string) string { func trimRightSlash(s string) string {
for len(s) > 0 && s[len(s)-1] == '/' { for len(s) > 0 && s[len(s)-1] == '/' {
s = s[:len(s)-1] s = s[:len(s)-1]

View File

@@ -87,6 +87,12 @@ type createTopicBody struct {
SignupCloseAt string `json:"signup_close_at"` SignupCloseAt string `json:"signup_close_at"`
DebateStartAt string `json:"debate_start_at"` DebateStartAt string `json:"debate_start_at"`
DebateEndAt string `json:"debate_end_at"` DebateEndAt string `json:"debate_end_at"`
// Optional: per-topic announce-channel target. Both must be set
// (or both omitted = no broadcasts). Creator picks based on the
// debate's intended audience (different guilds may host different
// communities, different channels may serve different categories).
AnnounceGuildBaseURL string `json:"announce_guild_base_url,omitempty"`
AnnounceChannelID string `json:"announce_channel_id,omitempty"`
} }
// POST /api/topics // POST /api/topics
@@ -119,16 +125,31 @@ func (h *TopicsHandler) Create(w http.ResponseWriter, r *http.Request) {
http.Error(w, err.Error(), http.StatusBadRequest) http.Error(w, err.Error(), http.StatusBadRequest)
return return
} }
// Announce target: both fields required or both empty; one-of-two
// is a config error caught here rather than silently treated as
// "no broadcast".
var aGuild, aChannel *string
if body.AnnounceGuildBaseURL != "" || body.AnnounceChannelID != "" {
if body.AnnounceGuildBaseURL == "" || body.AnnounceChannelID == "" {
http.Error(w, "announce_guild_base_url and announce_channel_id must both be set (or both empty for no broadcasts)", http.StatusBadRequest)
return
}
g, c := body.AnnounceGuildBaseURL, body.AnnounceChannelID
aGuild, aChannel = &g, &c
}
created, err := h.store.Create(r.Context(), store.CreateTopicInput{ created, err := h.store.Create(r.Context(), store.CreateTopicInput{
Title: body.Title, Title: body.Title,
Summary: body.Summary, Summary: body.Summary,
Visibility: models.Visibility(body.Visibility), Visibility: models.Visibility(body.Visibility),
VerdictSchemaID: body.VerdictSchemaID, VerdictSchemaID: body.VerdictSchemaID,
SignupOpenAt: parsed[0], SignupOpenAt: parsed[0],
SignupCloseAt: parsed[1], SignupCloseAt: parsed[1],
DebateStartAt: parsed[2], DebateStartAt: parsed[2],
DebateEndAt: parsed[3], DebateEndAt: parsed[3],
CreatorUserID: caller.ID, CreatorUserID: caller.ID,
AnnounceGuildBaseURL: aGuild,
AnnounceChannelID: aChannel,
}) })
if err != nil { if err != nil {
http.Error(w, "create failed: "+err.Error(), http.StatusInternalServerError) http.Error(w, "create failed: "+err.Error(), http.StatusInternalServerError)

View File

@@ -119,14 +119,20 @@ func (h *VerdictHandler) Submit(w http.ResponseWriter, r *http.Request) {
// Lifecycle broadcast — completed event. Best-effort; runs async // Lifecycle broadcast — completed event. Best-effort; runs async
// outside the request context so a slow Fabric doesn't slow the // outside the request context so a slow Fabric doesn't slow the
// judge's response. // judge's response. Target is per-topic (nil announcer or nil
// target on topic → silently skipped by announcer).
if h.announcer != nil { if h.announcer != nil {
go func(t *fabric.Announcer, tID, title string, judge string) { var tgt fabric.Target
summary := fmt.Sprintf("verdict published by judge=%s. Use dialectic_view_verdict to see the structured result.", judge) if topic.AnnounceGuildBaseURL != nil && topic.AnnounceChannelID != nil {
if err := t.PostLifecycleEvent(context.Background(), tID, title, "completed", summary); err != nil { tgt = fabric.Target{
// silent failure; the announcer logs internally GuildBaseURL: *topic.AnnounceGuildBaseURL,
ChannelID: *topic.AnnounceChannelID,
} }
}(h.announcer, verdict.TopicID, topic.Title, caller.ID) }
go func(t *fabric.Announcer, tID, title, judge string, target fabric.Target) {
summary := fmt.Sprintf("verdict published by judge=%s. Use dialectic_view_verdict to see the structured result.", judge)
_ = t.PostLifecycleEvent(context.Background(), target, tID, title, "completed", summary)
}(h.announcer, verdict.TopicID, topic.Title, caller.ID, tgt)
} }
writeJSON(w, http.StatusCreated, map[string]any{ writeJSON(w, http.StatusCreated, map[string]any{

View File

@@ -63,12 +63,7 @@ func Mount(cfg *config.Config, db *sqlx.DB, version string) http.Handler {
topicsH := handlers.NewTopicsHandler(topicStore) topicsH := handlers.NewTopicsHandler(topicStore)
signupsH := handlers.NewSignupsHandler(topicStore, signupStore) signupsH := handlers.NewSignupsHandler(topicStore, signupStore)
argsH := handlers.NewArgumentsHandler(topicStore, campStore, roundStore, argStore) argsH := handlers.NewArgumentsHandler(topicStore, campStore, roundStore, argStore)
announcer := fabric.NewAnnouncer(fabric.AnnounceConfig{ announcer := fabric.NewAnnouncer(cfg.FabricSystemAPIKey)
GuildBaseURL: cfg.FabricGuildBaseURL,
ChannelID: cfg.FabricAnnounceChannelID,
SystemAPIKey: cfg.FabricSystemAPIKey,
BotBearerToken: cfg.FabricBotBearerToken,
})
verdictH := handlers.NewVerdictHandler(topicStore, campStore, verdictStore, announcer) verdictH := handlers.NewVerdictHandler(topicStore, campStore, verdictStore, announcer)
adminH := handlers.NewAdminHandler(db, cfg.AgentAPIKeyPepper, cfg.DialecticAdminAPIKey) adminH := handlers.NewAdminHandler(db, cfg.AgentAPIKeyPepper, cfg.DialecticAdminAPIKey)

View File

@@ -49,8 +49,15 @@ type Topic struct {
VisibilityChangedBy *string `db:"visibility_changed_by" json:"visibility_changed_by,omitempty"` VisibilityChangedBy *string `db:"visibility_changed_by" json:"visibility_changed_by,omitempty"`
VisibilityChangedAt *time.Time `db:"visibility_changed_at" json:"visibility_changed_at,omitempty"` VisibilityChangedAt *time.Time `db:"visibility_changed_at" json:"visibility_changed_at,omitempty"`
CancelledReason *string `db:"cancelled_reason" json:"cancelled_reason,omitempty"` CancelledReason *string `db:"cancelled_reason" json:"cancelled_reason,omitempty"`
CreatedAt time.Time `db:"created_at" json:"created_at"` // AnnounceGuildBaseURL + AnnounceChannelID: per-topic broadcast
UpdatedAt time.Time `db:"updated_at" json:"updated_at"` // target. Set by the proposing agent at create time (they pick which
// Fabric guild + announce channel reflects this topic's audience).
// Both nullable; null on either side disables lifecycle broadcasts
// for this topic (creator opted out).
AnnounceGuildBaseURL *string `db:"announce_guild_base_url" json:"announce_guild_base_url,omitempty"`
AnnounceChannelID *string `db:"announce_channel_id" json:"announce_channel_id,omitempty"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
} }
// IsCampValid returns true iff c is one of pro|con|judge. // IsCampValid returns true iff c is one of pro|con|judge.

View File

@@ -135,7 +135,7 @@ func (t *Ticker) tickOnce(ctx context.Context) {
topicID, res.CancelReason) topicID, res.CancelReason)
if err == nil { if err == nil {
go t.broadcastLifecycle(topic, "cancelled", go t.broadcastLifecycle(topic, "cancelled",
fmt.Sprintf("debate cancelled at signup close %s", res.CancelReason)) fmt.Sprintf("debate cancelled at signup close - %s", res.CancelReason))
} }
return err return err
} }
@@ -249,12 +249,15 @@ func (t *Ticker) applyOne(ctx context.Context, topicID string,
// broadcastLifecycle wraps the announcer's lifecycle-event post with // broadcastLifecycle wraps the announcer's lifecycle-event post with
// the standard signup_closed / cancelled / debating / completed // the standard signup_closed / cancelled / debating / completed
// formats. Best-effort; runs in its own goroutine outside any tx. // formats. Best-effort; runs in its own goroutine outside any tx.
// Target is resolved from the topic's per-topic announce columns;
// null on either column → announcer skips with a log (creator opted
// out of broadcasts).
func (t *Ticker) broadcastLifecycle(topic *models.Topic, kind, summary string) { func (t *Ticker) broadcastLifecycle(topic *models.Topic, kind, summary string) {
if topic == nil { if topic == nil {
return return
} }
if err := t.announcer.PostLifecycleEvent( if err := t.announcer.PostLifecycleEvent(
context.Background(), topic.ID, topic.Title, kind, summary, context.Background(), topicTarget(topic), topic.ID, topic.Title, kind, summary,
); err != nil { ); err != nil {
log.Printf("orchestrator: lifecycle broadcast topic=%s kind=%s failed: %v", topic.ID, kind, err) log.Printf("orchestrator: lifecycle broadcast topic=%s kind=%s failed: %v", topic.ID, kind, err)
} }
@@ -265,7 +268,7 @@ func (t *Ticker) broadcastAnnouncement(topic *models.Topic) {
return return
} }
if err := t.announcer.PostTopicAnnouncement( if err := t.announcer.PostTopicAnnouncement(
context.Background(), context.Background(), topicTarget(topic),
topic.ID, topic.Title, topic.Summary, topic.ID, topic.Title, topic.Summary,
topic.SignupOpenAt, topic.SignupCloseAt, topic.SignupOpenAt, topic.SignupCloseAt,
topic.DebateStartAt, topic.DebateEndAt, topic.DebateStartAt, topic.DebateEndAt,
@@ -274,3 +277,16 @@ func (t *Ticker) broadcastAnnouncement(topic *models.Topic) {
log.Printf("orchestrator: announce topic=%s failed: %v", topic.ID, err) log.Printf("orchestrator: announce topic=%s failed: %v", topic.ID, err)
} }
} }
// topicTarget extracts the per-topic announce target from the topic
// row; returns zero-value Target if either column is null (which the
// announcer treats as "skip").
func topicTarget(topic *models.Topic) fabric.Target {
if topic.AnnounceGuildBaseURL == nil || topic.AnnounceChannelID == nil {
return fabric.Target{}
}
return fabric.Target{
GuildBaseURL: *topic.AnnounceGuildBaseURL,
ChannelID: *topic.AnnounceChannelID,
}
}

View File

@@ -23,25 +23,29 @@ type TopicStore struct {
func NewTopicStore(db *sqlx.DB) *TopicStore { return &TopicStore{db: db} } func NewTopicStore(db *sqlx.DB) *TopicStore { return &TopicStore{db: db} }
type CreateTopicInput struct { type CreateTopicInput struct {
Title string Title string
Summary string Summary string
Visibility models.Visibility Visibility models.Visibility
VerdictSchemaID string VerdictSchemaID string
SignupOpenAt time.Time SignupOpenAt time.Time
SignupCloseAt time.Time SignupCloseAt time.Time
DebateStartAt time.Time DebateStartAt time.Time
DebateEndAt time.Time DebateEndAt time.Time
CreatorUserID string CreatorUserID string
AnnounceGuildBaseURL *string // optional; null = no broadcasts for this topic
AnnounceChannelID *string
} }
func (s *TopicStore) Create(ctx context.Context, in CreateTopicInput) (*models.Topic, error) { func (s *TopicStore) Create(ctx context.Context, in CreateTopicInput) (*models.Topic, error) {
id := uuid.NewString() id := uuid.NewString()
_, err := s.db.ExecContext(ctx, ` _, err := s.db.ExecContext(ctx, `
INSERT INTO topics (id, title, summary, visibility, verdict_schema_id, INSERT INTO topics (id, title, summary, visibility, verdict_schema_id,
signup_open_at, signup_close_at, debate_start_at, debate_end_at, creator_user_id) signup_open_at, signup_close_at, debate_start_at, debate_end_at,
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, creator_user_id, announce_guild_base_url, announce_channel_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
id, in.Title, in.Summary, in.Visibility, in.VerdictSchemaID, id, in.Title, in.Summary, in.Visibility, in.VerdictSchemaID,
in.SignupOpenAt, in.SignupCloseAt, in.DebateStartAt, in.DebateEndAt, in.CreatorUserID) in.SignupOpenAt, in.SignupCloseAt, in.DebateStartAt, in.DebateEndAt,
in.CreatorUserID, in.AnnounceGuildBaseURL, in.AnnounceChannelID)
if err != nil { if err != nil {
return nil, fmt.Errorf("insert topic: %w", err) return nil, fmt.Errorf("insert topic: %w", err)
} }

View File

@@ -52,12 +52,8 @@ func main() {
log.Printf("migrations: ok") log.Printf("migrations: ok")
// Wire orchestrator + Fabric announcer + start the ticker. // Wire orchestrator + Fabric announcer + start the ticker.
announcer := fabric.NewAnnouncer(fabric.AnnounceConfig{ // Per Phase 3.5: target is per-topic, only the system key stays in env.
GuildBaseURL: cfg.FabricGuildBaseURL, announcer := fabric.NewAnnouncer(cfg.FabricSystemAPIKey)
ChannelID: cfg.FabricAnnounceChannelID,
SystemAPIKey: cfg.FabricSystemAPIKey,
BotBearerToken: cfg.FabricBotBearerToken,
})
topicStore := store.NewTopicStore(conn) topicStore := store.NewTopicStore(conn)
signupStore := store.NewSignupStore(conn) signupStore := store.NewSignupStore(conn)
campStore := store.NewCampStore(conn) campStore := store.NewCampStore(conn)