From a43ff2de625d37e42a6981c1e1d971dad4ef69c6 Mon Sep 17 00:00:00 2001 From: hzhang Date: Sat, 23 May 2026 17:53:30 +0100 Subject: [PATCH] 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. --- internal/config/config.go | 59 ++++--- .../migrations/002_topic_announce_target.sql | 10 ++ internal/fabric/announce.go | 149 +++++++----------- internal/httpapi/handlers/topics.go | 39 +++-- internal/httpapi/handlers/verdict.go | 18 ++- internal/httpapi/routes.go | 7 +- internal/models/topic.go | 11 +- internal/orchestrator/ticker.go | 22 ++- internal/store/topic_store.go | 28 ++-- main.go | 8 +- 10 files changed, 190 insertions(+), 161 deletions(-) create mode 100644 internal/db/migrations/002_topic_announce_target.sql diff --git a/internal/config/config.go b/internal/config/config.go index cbeb5c7..d4ea7c5 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -66,14 +66,24 @@ type Config struct { OIDCIssuer string OIDCClientID string - // Fabric announce coupling (Phase 2D). All four required to enable; - // any empty → announcer becomes a no-op (logs intent, skips post). - // This lets the orchestrator run in environments where the Fabric - // coupling hasn't been wired yet. - FabricGuildBaseURL string // e.g. https://fabric-api.hangman-lab.top - FabricAnnounceChannelID string - FabricSystemAPIKey string // x-fabric-system-key value (env: FABRIC_SYSTEM_API_KEY) - FabricBotBearerToken string // Authorization Bearer for the dialectic-system Fabric user + // Fabric announce coupling. + // + // As of Phase 3.5: only the system api key stays in env. Guild base + // URL + announce channel ID are PER-TOPIC, supplied by the proposing + // agent at create time and stored on the topic row. Different topics + // can broadcast to different guilds/channels from the same backend. + // + // 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. OrchestratorTickInterval time.Duration @@ -81,24 +91,21 @@ type Config struct { func LoadFromEnv() (*Config, error) { c := &Config{ - Mode: getenv("ENV_MODE", "dev"), - HTTPAddr: getenv("HTTP_ADDR", "0.0.0.0:8090"), - CORSAllowOrigins: splitCSV(getenv("CORS_ALLOW_ORIGINS", "*")), - DBHost: getenv("DB_HOST", "127.0.0.1"), - DBPort: getenv("DB_PORT", "3306"), - DBName: getenv("DB_NAME", "dialectic"), - DBUser: getenv("DB_USER", "dialectic"), - DBPassword: os.Getenv("DB_PASSWORD"), - SystemAPIKey: os.Getenv("SYSTEM_API_KEY"), - AgentAPIKeyPepper: os.Getenv("AGENT_API_KEY_PEPPER"), - OIDCDevBypassToken: os.Getenv("OIDC_DEV_BYPASS_TOKEN"), - DialecticAdminAPIKey: os.Getenv("DIALECTIC_ADMIN_API_KEY"), - OIDCIssuer: os.Getenv("OIDC_ISSUER"), - OIDCClientID: os.Getenv("OIDC_CLIENT_ID"), - FabricGuildBaseURL: os.Getenv("FABRIC_GUILD_BASE_URL"), - FabricAnnounceChannelID: os.Getenv("FABRIC_ANNOUNCE_CHANNEL_ID"), - FabricSystemAPIKey: os.Getenv("FABRIC_SYSTEM_API_KEY"), - FabricBotBearerToken: os.Getenv("FABRIC_BOT_BEARER_TOKEN"), + Mode: getenv("ENV_MODE", "dev"), + HTTPAddr: getenv("HTTP_ADDR", "0.0.0.0:8090"), + CORSAllowOrigins: splitCSV(getenv("CORS_ALLOW_ORIGINS", "*")), + DBHost: getenv("DB_HOST", "127.0.0.1"), + DBPort: getenv("DB_PORT", "3306"), + DBName: getenv("DB_NAME", "dialectic"), + DBUser: getenv("DB_USER", "dialectic"), + DBPassword: os.Getenv("DB_PASSWORD"), + SystemAPIKey: os.Getenv("SYSTEM_API_KEY"), + AgentAPIKeyPepper: os.Getenv("AGENT_API_KEY_PEPPER"), + OIDCDevBypassToken: os.Getenv("OIDC_DEV_BYPASS_TOKEN"), + DialecticAdminAPIKey: os.Getenv("DIALECTIC_ADMIN_API_KEY"), + OIDCIssuer: os.Getenv("OIDC_ISSUER"), + OIDCClientID: os.Getenv("OIDC_CLIENT_ID"), + FabricSystemAPIKey: os.Getenv("DIALECTIC_FABRIC_SYSTEM_API_KEY"), } if d := os.Getenv("ORCHESTRATOR_TICK_INTERVAL"); d != "" { if parsed, err := time.ParseDuration(d); err == nil { diff --git a/internal/db/migrations/002_topic_announce_target.sql b/internal/db/migrations/002_topic_announce_target.sql new file mode 100644 index 0000000..7123cde --- /dev/null +++ b/internal/db/migrations/002_topic_announce_target.sql @@ -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; diff --git a/internal/fabric/announce.go b/internal/fabric/announce.go index 5211bc6..f7f6a28 100644 --- a/internal/fabric/announce.go +++ b/internal/fabric/announce.go @@ -1,19 +1,15 @@ // 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) +// 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. // -// 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. +// 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 ( @@ -27,62 +23,74 @@ import ( "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 + systemAPIKey string + requestTimeout time.Duration + client *http.Client } -func NewAnnouncer(cfg AnnounceConfig) *Announcer { - if cfg.RequestTimeout <= 0 { - cfg.RequestTimeout = 5 * time.Second - } +func NewAnnouncer(systemAPIKey string) *Announcer { + timeout := 5 * time.Second return &Announcer{ - cfg: cfg, - client: &http.Client{Timeout: cfg.RequestTimeout}, + systemAPIKey: systemAPIKey, + requestTimeout: timeout, + client: &http.Client{Timeout: timeout}, } } -// 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 != "" +// 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 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 { +// 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 (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 } - body := map[string]any{ - "content": formatAnnouncement(topicID, title, summary, signupOpen, signupClose, debateStart, debateEnd, verdictSchemaID), - } - raw, _ := json.Marshal(body) + body, _ := json.Marshal(map[string]any{"content": content}) 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)) + 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("authorization", "Bearer "+a.cfg.BotBearerToken) - req.Header.Set("x-fabric-system-key", a.cfg.SystemAPIKey) + req.Header.Set("x-fabric-system-key", a.systemAPIKey) resp, err := a.client.Do(req) if err != nil { @@ -92,7 +100,7 @@ func (a *Announcer) PostTopicAnnouncement(ctx context.Context, topicID, title, s 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)) + 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 @@ -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 { for len(s) > 0 && s[len(s)-1] == '/' { s = s[:len(s)-1] diff --git a/internal/httpapi/handlers/topics.go b/internal/httpapi/handlers/topics.go index f7c14c3..d1e81c8 100644 --- a/internal/httpapi/handlers/topics.go +++ b/internal/httpapi/handlers/topics.go @@ -87,6 +87,12 @@ type createTopicBody struct { SignupCloseAt string `json:"signup_close_at"` DebateStartAt string `json:"debate_start_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 @@ -119,16 +125,31 @@ func (h *TopicsHandler) Create(w http.ResponseWriter, r *http.Request) { http.Error(w, err.Error(), http.StatusBadRequest) 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{ - Title: body.Title, - Summary: body.Summary, - Visibility: models.Visibility(body.Visibility), - VerdictSchemaID: body.VerdictSchemaID, - SignupOpenAt: parsed[0], - SignupCloseAt: parsed[1], - DebateStartAt: parsed[2], - DebateEndAt: parsed[3], - CreatorUserID: caller.ID, + Title: body.Title, + Summary: body.Summary, + Visibility: models.Visibility(body.Visibility), + VerdictSchemaID: body.VerdictSchemaID, + SignupOpenAt: parsed[0], + SignupCloseAt: parsed[1], + DebateStartAt: parsed[2], + DebateEndAt: parsed[3], + CreatorUserID: caller.ID, + AnnounceGuildBaseURL: aGuild, + AnnounceChannelID: aChannel, }) if err != nil { http.Error(w, "create failed: "+err.Error(), http.StatusInternalServerError) diff --git a/internal/httpapi/handlers/verdict.go b/internal/httpapi/handlers/verdict.go index 8fb4073..9465a19 100644 --- a/internal/httpapi/handlers/verdict.go +++ b/internal/httpapi/handlers/verdict.go @@ -119,14 +119,20 @@ func (h *VerdictHandler) Submit(w http.ResponseWriter, r *http.Request) { // Lifecycle broadcast — completed event. Best-effort; runs async // 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 { - go func(t *fabric.Announcer, tID, title string, judge string) { - summary := fmt.Sprintf("verdict published by judge=%s. Use dialectic_view_verdict to see the structured result.", judge) - if err := t.PostLifecycleEvent(context.Background(), tID, title, "completed", summary); err != nil { - // silent failure; the announcer logs internally + var tgt fabric.Target + if topic.AnnounceGuildBaseURL != nil && topic.AnnounceChannelID != nil { + tgt = fabric.Target{ + 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{ diff --git a/internal/httpapi/routes.go b/internal/httpapi/routes.go index a0e4854..5a2347e 100644 --- a/internal/httpapi/routes.go +++ b/internal/httpapi/routes.go @@ -63,12 +63,7 @@ func Mount(cfg *config.Config, db *sqlx.DB, version string) http.Handler { topicsH := handlers.NewTopicsHandler(topicStore) signupsH := handlers.NewSignupsHandler(topicStore, signupStore) argsH := handlers.NewArgumentsHandler(topicStore, campStore, roundStore, argStore) - announcer := fabric.NewAnnouncer(fabric.AnnounceConfig{ - GuildBaseURL: cfg.FabricGuildBaseURL, - ChannelID: cfg.FabricAnnounceChannelID, - SystemAPIKey: cfg.FabricSystemAPIKey, - BotBearerToken: cfg.FabricBotBearerToken, - }) + announcer := fabric.NewAnnouncer(cfg.FabricSystemAPIKey) verdictH := handlers.NewVerdictHandler(topicStore, campStore, verdictStore, announcer) adminH := handlers.NewAdminHandler(db, cfg.AgentAPIKeyPepper, cfg.DialecticAdminAPIKey) diff --git a/internal/models/topic.go b/internal/models/topic.go index c99e827..c625415 100644 --- a/internal/models/topic.go +++ b/internal/models/topic.go @@ -49,8 +49,15 @@ type Topic struct { VisibilityChangedBy *string `db:"visibility_changed_by" json:"visibility_changed_by,omitempty"` VisibilityChangedAt *time.Time `db:"visibility_changed_at" json:"visibility_changed_at,omitempty"` CancelledReason *string `db:"cancelled_reason" json:"cancelled_reason,omitempty"` - CreatedAt time.Time `db:"created_at" json:"created_at"` - UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + // AnnounceGuildBaseURL + AnnounceChannelID: per-topic broadcast + // 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. diff --git a/internal/orchestrator/ticker.go b/internal/orchestrator/ticker.go index 0db8501..4601fcf 100644 --- a/internal/orchestrator/ticker.go +++ b/internal/orchestrator/ticker.go @@ -135,7 +135,7 @@ func (t *Ticker) tickOnce(ctx context.Context) { topicID, res.CancelReason) if err == nil { 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 } @@ -249,12 +249,15 @@ func (t *Ticker) applyOne(ctx context.Context, topicID string, // broadcastLifecycle wraps the announcer's lifecycle-event post with // the standard signup_closed / cancelled / debating / completed // 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) { if topic == nil { return } 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 { 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 } if err := t.announcer.PostTopicAnnouncement( - context.Background(), + context.Background(), topicTarget(topic), topic.ID, topic.Title, topic.Summary, topic.SignupOpenAt, topic.SignupCloseAt, 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) } } + +// 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, + } +} diff --git a/internal/store/topic_store.go b/internal/store/topic_store.go index 766d291..102f31a 100644 --- a/internal/store/topic_store.go +++ b/internal/store/topic_store.go @@ -23,25 +23,29 @@ type TopicStore struct { func NewTopicStore(db *sqlx.DB) *TopicStore { return &TopicStore{db: db} } type CreateTopicInput struct { - Title string - Summary string - Visibility models.Visibility - VerdictSchemaID string - SignupOpenAt time.Time - SignupCloseAt time.Time - DebateStartAt time.Time - DebateEndAt time.Time - CreatorUserID string + Title string + Summary string + Visibility models.Visibility + VerdictSchemaID string + SignupOpenAt time.Time + SignupCloseAt time.Time + DebateStartAt time.Time + DebateEndAt time.Time + 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) { id := uuid.NewString() _, err := s.db.ExecContext(ctx, ` 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) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + signup_open_at, signup_close_at, debate_start_at, debate_end_at, + creator_user_id, announce_guild_base_url, announce_channel_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, 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 { return nil, fmt.Errorf("insert topic: %w", err) } diff --git a/main.go b/main.go index 96a85f3..aa751dd 100644 --- a/main.go +++ b/main.go @@ -52,12 +52,8 @@ func main() { log.Printf("migrations: ok") // Wire orchestrator + Fabric announcer + start the ticker. - announcer := fabric.NewAnnouncer(fabric.AnnounceConfig{ - GuildBaseURL: cfg.FabricGuildBaseURL, - ChannelID: cfg.FabricAnnounceChannelID, - SystemAPIKey: cfg.FabricSystemAPIKey, - BotBearerToken: cfg.FabricBotBearerToken, - }) + // Per Phase 3.5: target is per-topic, only the system key stays in env. + announcer := fabric.NewAnnouncer(cfg.FabricSystemAPIKey) topicStore := store.NewTopicStore(conn) signupStore := store.NewSignupStore(conn) campStore := store.NewCampStore(conn)