// Package ads owns the server-driven advertising banner ("advertising network"): // operator-managed campaigns, their bilingual messages and the global display // timings the client's one-line announcement strip rotates through. // // A campaign is one placement order with a show weight (an integer percent). // Simultaneously active campaigns compete for display slots in proportion to // their weights; the single perpetual default campaign fills the unsold // remainder up to 100%. The actual rotation runs client-side (a smooth weighted // round-robin over campaigns, round-robin over a campaign's messages); the server // only computes the effective weighted, language-resolved set a viewer should // rotate, via ActiveSet. Who sees a banner at all is decided by Eligible. package ads import ( "errors" "time" "github.com/google/uuid" ) // ErrNotFound is returned when a campaign or message does not exist. var ErrNotFound = errors.New("ads: not found") // ErrDefaultImmutable is returned when an operation is refused on the perpetual // default campaign (it cannot be deleted, disabled, windowed, or have its // weight or default flag changed). var ErrDefaultImmutable = errors.New("ads: the default campaign cannot be modified that way") // ErrValidation wraps an operator-facing validation failure (a bad weight, // window or message body). var ErrValidation = errors.New("ads: validation") // ColorSet is an optional per-campaign colour override for the banner strip: // background, foreground (text) and link, each a "#rrggbb" hex string. The three // are set together or the whole set is absent (a nil *ColorSet). type ColorSet struct { Bg string Fg string Link string } // Campaign is one advertising placement order with its messages. type Campaign struct { ID uuid.UUID // uuid.Nil on create Name string Weight int // show percent, 1..100; nominal (ignored) for the default IsDefault bool Enabled bool StartsAt *time.Time // nil = open-ended start; always nil for the default EndsAt *time.Time // nil = open-ended end; always nil for the default Messages []Message // OverrideAll paints the strip on every theme; OverrideDark, when set, further // overrides the dark theme (the client resolves dark ← dark ?? all ?? token, // light ← all ?? token). Both are nil for the default campaign and for a // campaign that keeps the neutral theme tokens. Non-default only. OverrideAll *ColorSet OverrideDark *ColorSet // Urgent forces the banner on every viewer (bypassing eligibility) and, while // any urgent campaign is active, suppresses every non-urgent campaign and the // default remainder. Non-default only; always false for the default campaign. Urgent bool CreatedAt time.Time UpdatedAt time.Time } // Message is one bilingual creative of a campaign. Both bodies are mandatory; // the client shows the variant for the viewer's bot (service) language. Position // orders the messages within a campaign (the round-robin order). type Message struct { ID uuid.UUID // uuid.Nil on create CampaignID uuid.UUID Position int BodyEn string BodyRu string } // Timings are the global banner display timings, in milliseconds except // ScrollPxPerSec. HoldMs is how long one message shows; EdgePauseMs and // ScrollPxPerSec drive the scroll of a message wider than the strip; a // transition between messages is FadeOutMs then GapMs then FadeInMs. type Timings struct { HoldMs int EdgePauseMs int ScrollPxPerSec int FadeOutMs int GapMs int FadeInMs int } // ActiveCampaign is one campaign in the resolved rotation feed sent to a client: // its GCD-reduced show weight and its messages, already resolved to the viewer's // language and in display (round-robin) order, plus the optional colour overrides // the client applies to the strip (nil = the neutral theme tokens). Urgency is not // carried here: it is resolved server-side into the set's membership and the // eligibility bypass, so the client only ever renders what it is sent. type ActiveCampaign struct { Weight int Messages []string OverrideAll *ColorSet OverrideDark *ColorSet } // computeActiveSet builds the resolved rotation feed from the enabled campaigns // at time now, in language lang, and reports whether the feed is an urgent one. // Campaigns outside their validity window, and campaigns with no messages, are // dropped. // // While any active campaign is urgent, the feed is the urgent campaigns alone — // every non-urgent timed campaign and the default remainder are suppressed for // the duration (and the caller shows the feed to every viewer, bypassing // eligibility). Otherwise the default campaign's effective weight is the // remainder up to 100% — max(0, 100 - sum of active timed weights) — so it fills // unsold inventory and is dropped entirely when timed campaigns already reach // 100%. Weights are then reduced by their GCD so the fair rotation cycle stays // short. The input is expected to be the enabled campaigns (ActiveCampaigns); // disabled ones must already be excluded. func computeActiveSet(campaigns []Campaign, now time.Time, lang string) ([]ActiveCampaign, bool) { var timed []Campaign var def *Campaign for i := range campaigns { c := campaigns[i] if len(c.Messages) == 0 { continue // a campaign with no creative cannot fill a slot } if c.IsDefault { def = &campaigns[i] continue } if withinWindow(c, now) { timed = append(timed, c) } } if urgent := filterUrgent(timed); len(urgent) > 0 { out := make([]ActiveCampaign, 0, len(urgent)) for _, c := range urgent { out = append(out, activeFrom(c, lang)) } reduceByGCD(out) return out, true } sumTimed := 0 for _, c := range timed { sumTimed += c.Weight } out := make([]ActiveCampaign, 0, len(timed)+1) for _, c := range timed { out = append(out, activeFrom(c, lang)) } if def != nil { if dw := 100 - sumTimed; dw > 0 { a := activeFrom(*def, lang) a.Weight = dw // the default's stored weight is nominal; it fills the remainder out = append(out, a) } } reduceByGCD(out) return out, false } // activeFrom projects a campaign to its rotation-feed entry: its show weight, its // language-resolved messages and its colour overrides (carried through by // reference, they are read-only). func activeFrom(c Campaign, lang string) ActiveCampaign { return ActiveCampaign{ Weight: c.Weight, Messages: resolveBodies(c.Messages, lang), OverrideAll: c.OverrideAll, OverrideDark: c.OverrideDark, } } // filterUrgent returns the urgent campaigns among cs, preserving order. It is the // preempt selector: a non-empty result makes the whole feed urgent. func filterUrgent(cs []Campaign) []Campaign { var out []Campaign for _, c := range cs { if c.Urgent { out = append(out, c) } } return out } // ActiveAt reports whether the campaign would rotate at time now: enabled, and // either the perpetual default or within its validity window. It mirrors the // filter computeActiveSet applies (a campaign with no messages still reports // active here — that is a content gap the console surfaces, not an inactive // campaign). func (c Campaign) ActiveAt(now time.Time) bool { return c.Enabled && (c.IsDefault || withinWindow(c, now)) } // withinWindow reports whether now lies inside the campaign's validity window. A // nil bound is open-ended on that side. func withinWindow(c Campaign, now time.Time) bool { if c.StartsAt != nil && now.Before(*c.StartsAt) { return false } if c.EndsAt != nil && now.After(*c.EndsAt) { return false } return true } // resolveBodies projects each message to the body for lang ("ru" picks the // Russian body; anything else picks English), preserving display order. func resolveBodies(msgs []Message, lang string) []string { out := make([]string, len(msgs)) for i, m := range msgs { if lang == "ru" { out[i] = m.BodyRu } else { out[i] = m.BodyEn } } return out } // reduceByGCD divides every weight by the greatest common divisor of all weights // (in place), shrinking a {50,30,20} set to {5,3,2} and a lone {100} to {1}. A // no-op when the set is empty or already coprime. func reduceByGCD(cs []ActiveCampaign) { g := 0 for _, c := range cs { g = gcd(g, c.Weight) } if g <= 1 { return } for i := range cs { cs[i].Weight /= g } } // gcd returns the greatest common divisor of a and b (gcd(0, n) == n). func gcd(a, b int) int { for b != 0 { a, b = b, a%b } if a < 0 { return -a } return a }