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)