added ability to broadcast

This commit is contained in:
onysd 2026-08-06 15:22:36 +03:00
parent 7d41cbeb1e
commit 2491088e81
31 changed files with 1607 additions and 18 deletions

View file

@ -57,6 +57,7 @@ const (
ActionSetStarGiftSortOrder = "gifts.set_sort_order"
ActionGiveGift = "gifts.give"
ActionCreateBot = "bot.create"
ActionCreateBroadcast = "broadcast.create"
ActionDeleteBot = "bot.delete"
ActionExportBotToken = "bot.export_token"
ActionSetStickerSetArchived = "stickers.set_archived"
@ -233,6 +234,21 @@ type StarsService interface {
Credit(ctx context.Context, userID, amount int64, reason domain.StarsTransactionReason, peer domain.Peer, title, desc string) (domain.StarsBalance, error)
}
// BroadcastService creates and lists system broadcast campaigns (a message
// from domain.OfficialSystemUserID to all or a hand-picked list of users).
// Delivery itself happens out-of-band via a worker draining the durable
// recipient outbox created here -- this interface only enqueues and reads
// back, so CreateBroadcast never blocks on however many recipients there
// are. Resolving "all users" into an explicit id list is the caller's job
// (cmd/telesrv-admin's readstore, the same place every other account list
// query already lives), not this service's -- it always receives an
// already-resolved id list.
type BroadcastService interface {
Create(ctx context.Context, message string, targetMode domain.BroadcastTargetMode, recipientUserIDs []int64, createdBy string) (domain.Broadcast, error)
List(ctx context.Context, beforeID int64, limit int) ([]domain.Broadcast, bool, error)
Get(ctx context.Context, id int64) (domain.Broadcast, bool, error)
}
type StarsNotifier interface {
NotifyStarsBalanceChanged(ctx context.Context, balance domain.StarsBalance) error
}
@ -428,7 +444,9 @@ type Dependencies struct {
// Account carries the login-email factor -- a separate app service from
// Users, since login email lives in account_passwords, not users.
Account AccountService
Now func() time.Time
// Broadcast is the system-broadcast (777000) create/list/get surface.
Broadcast BroadcastService
Now func() time.Time
}
type Service struct {
@ -458,6 +476,7 @@ type Service struct {
verification VerificationService
botVerification BotVerificationService
account AccountService
broadcast BroadcastService
now func() time.Time
}
@ -545,6 +564,9 @@ func (s *Service) Configure(deps Dependencies) *Service {
if deps.Account != nil {
s.account = deps.Account
}
if deps.Broadcast != nil {
s.broadcast = deps.Broadcast
}
if deps.Now != nil {
s.now = deps.Now
}
@ -1032,6 +1054,18 @@ type DeleteBotRequest struct {
BotUserID int64 `json:"bot_user_id"`
}
// CreateBroadcastRequest's UserIDs is always an already-resolved recipient
// list -- for TargetMode "all" the caller (cmd/telesrv-admin's readstore
// proxy) has already turned "every user" into an explicit id list before
// this reaches the admin service, so CreateBroadcast never has to know how
// to enumerate accounts itself.
type CreateBroadcastRequest struct {
CommandMeta
Message string `json:"message"`
TargetMode string `json:"target_mode"`
UserIDs []int64 `json:"user_ids"`
}
type ExportBotTokenRequest struct {
CommandMeta
BotUserID int64 `json:"bot_user_id"`
@ -2049,6 +2083,55 @@ func (s *Service) DeleteBot(ctx context.Context, req DeleteBotRequest) (CommandR
})
}
// CreateBroadcast enqueues a system-broadcast (a message from
// domain.OfficialSystemUserID) to an already-resolved recipient list.
// Delivery happens out-of-band via the broadcast worker draining the durable
// recipient rows this creates -- the command completes as soon as the
// recipient snapshot is written, never waiting on however many sends that
// implies.
func (s *Service) CreateBroadcast(ctx context.Context, req CreateBroadcastRequest) (CommandResult, error) {
if s == nil || s.broadcast == nil {
return CommandResult{}, fmt.Errorf("admin broadcast dependency is not configured")
}
message := strings.TrimSpace(req.Message)
if message == "" {
return CommandResult{}, domain.ErrBroadcastMessageEmpty
}
targetMode := domain.BroadcastTargetMode(req.TargetMode)
if targetMode != domain.BroadcastTargetAll && targetMode != domain.BroadcastTargetSelected {
return CommandResult{}, domain.ErrBroadcastInvalid
}
if len(req.UserIDs) == 0 {
return CommandResult{}, domain.ErrBroadcastNoRecipients
}
return s.runCommand(ctx, req.CommandMeta, ActionCreateBroadcast, 0, domain.Peer{}, req, func() (CommandResult, error) {
details := map[string]any{
"target_mode": string(targetMode),
"recipient_count": len(req.UserIDs),
"message_preview": truncateBroadcastPreview(message),
}
if req.DryRun {
return CommandResult{Message: "broadcast validated", Details: details}, nil
}
created, err := s.broadcast.Create(ctx, message, targetMode, req.UserIDs, req.CommandMeta.Actor)
if err != nil {
return CommandResult{Details: details}, err
}
details["broadcast_id"] = created.ID
details["total_count"] = created.TotalCount
return CommandResult{Message: "broadcast created", Details: details}, nil
})
}
func truncateBroadcastPreview(message string) string {
const maxPreview = 120
r := []rune(message)
if len(r) <= maxPreview {
return message
}
return string(r[:maxPreview]) + "…"
}
// ExportBotToken returns a non-system bot's current token (unrotated) via the
// audited runCommand wrapper. Like CreateBot's token, it travels only in
// transientDetails -- excluded from the stored/replayed command JSON so it

View file

@ -50,6 +50,7 @@ type Service interface {
SetChannelVerified(ctx context.Context, req admin.SetChannelVerifiedRequest) (admin.CommandResult, error)
SetChannelFlags(ctx context.Context, req admin.SetChannelFlagsRequest) (admin.CommandResult, error)
CreateBot(ctx context.Context, req admin.CreateBotRequest) (admin.CommandResult, error)
CreateBroadcast(ctx context.Context, req admin.CreateBroadcastRequest) (admin.CommandResult, error)
DeleteBot(ctx context.Context, req admin.DeleteBotRequest) (admin.CommandResult, error)
ExportBotToken(ctx context.Context, req admin.ExportBotTokenRequest) (admin.CommandResult, error)
SetSupport(ctx context.Context, req admin.SetSupportRequest) (admin.CommandResult, error)
@ -215,6 +216,7 @@ func (s *Server) routes() http.Handler {
mux.HandleFunc("POST /v1/channels/set-color", s.authenticated(s.handleSetChannelColor))
mux.HandleFunc("POST /v1/channels/set-emoji-status", s.authenticated(s.handleSetChannelEmojiStatus))
mux.HandleFunc("POST /v1/bots/create", s.authenticated(s.handleCreateBot))
mux.HandleFunc("POST /v1/broadcasts/create", s.authenticated(s.handleCreateBroadcast))
mux.HandleFunc("POST /v1/bots/delete", s.authenticated(s.handleDeleteBot))
mux.HandleFunc("POST /v1/bots/export-token", s.authenticated(s.handleExportBotToken))
mux.HandleFunc("POST /v1/messages/delete", s.authenticated(s.handleDeleteMessages))
@ -578,6 +580,15 @@ func (s *Server) handleCreateBot(w http.ResponseWriter, r *http.Request) {
writeCommandResult(w, result, err)
}
func (s *Server) handleCreateBroadcast(w http.ResponseWriter, r *http.Request) {
var req admin.CreateBroadcastRequest
if !decodeJSON(w, r, &req) {
return
}
result, err := s.svc.CreateBroadcast(r.Context(), req)
writeCommandResult(w, result, err)
}
func (s *Server) handleDeleteBot(w http.ResponseWriter, r *http.Request) {
var req admin.DeleteBotRequest
if !decodeJSON(w, r, &req) {

View file

@ -459,6 +459,10 @@ func (fakeService) CreateBot(_ context.Context, req admin.CreateBotRequest) (adm
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (fakeService) CreateBroadcast(_ context.Context, req admin.CreateBroadcastRequest) (admin.CommandResult, error) {
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (fakeService) DeleteBot(_ context.Context, req admin.DeleteBotRequest) (admin.CommandResult, error) {
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}

View file

@ -0,0 +1,159 @@
// Package broadcast implements admin-triggered system message campaigns:
// sending a message from the official system account (domain.OfficialSystemUserID,
// 777000) to every user or a hand-picked list. Delivery is a durable outbox
// (store.BroadcastStore's recipient rows) drained by a periodic Worker,
// mirroring internal/app/verification's notification outbox -- the admin
// action only snapshots the recipient list and returns, never sending
// potentially thousands of messages inline within one HTTP request.
package broadcast
import (
"context"
"fmt"
"hash/fnv"
"strconv"
"strings"
"go.uber.org/zap"
"telesrv/internal/domain"
"telesrv/internal/store"
)
// messageSender is the narrow port this package needs from
// store.MessageStore: sending a message with an arbitrary SenderUserID, the
// way internal/app/bots's sendServiceBotReplyResult calls it directly at the
// store layer rather than through the auth-checked app.messages.Service
// wrapper (which requires SenderUserID == the authenticated caller).
type messageSender interface {
SendPrivateText(ctx context.Context, req domain.SendPrivateTextRequest) (domain.SendPrivateTextResult, error)
}
// Service creates broadcasts and drains their delivery outbox.
type Service struct {
store store.BroadcastStore
messages messageSender
log *zap.Logger
}
// Option adjusts an optional Service dependency.
type Option func(*Service)
// NewService builds the broadcast service.
func NewService(st store.BroadcastStore, opts ...Option) *Service {
s := &Service{store: st, log: zap.NewNop()}
for _, opt := range opts {
opt(s)
}
return s
}
// WithMessageSender injects the store used to actually deliver a message.
func WithMessageSender(m messageSender) Option {
return func(s *Service) {
if m != nil {
s.messages = m
}
}
}
// WithLogger injects a logger (default zap.NewNop()).
func WithLogger(log *zap.Logger) Option {
return func(s *Service) {
if log != nil {
s.log = log
}
}
}
// Ready reports whether both the store and the sender are wired.
func (s *Service) Ready() bool { return s != nil && s.store != nil && s.messages != nil }
// Create validates and snapshots a new broadcast's recipient set, then
// returns immediately: delivery happens asynchronously via RunSendCycle, so
// this never blocks an admin HTTP request on however many recipients there
// are.
func (s *Service) Create(ctx context.Context, message string, targetMode domain.BroadcastTargetMode, recipientUserIDs []int64, createdBy string) (domain.Broadcast, error) {
if s == nil || s.store == nil {
return domain.Broadcast{}, fmt.Errorf("broadcast store is not configured")
}
message = strings.TrimSpace(message)
if message == "" {
return domain.Broadcast{}, domain.ErrBroadcastMessageEmpty
}
if targetMode != domain.BroadcastTargetAll && targetMode != domain.BroadcastTargetSelected {
return domain.Broadcast{}, domain.ErrBroadcastInvalid
}
return s.store.CreateBroadcast(ctx, message, targetMode, recipientUserIDs, createdBy)
}
// List pages broadcasts newest-first.
func (s *Service) List(ctx context.Context, beforeID int64, limit int) ([]domain.Broadcast, bool, error) {
if s == nil || s.store == nil {
return nil, false, nil
}
return s.store.ListBroadcasts(ctx, beforeID, limit)
}
// Get returns one broadcast.
func (s *Service) Get(ctx context.Context, id int64) (domain.Broadcast, bool, error) {
if s == nil || s.store == nil {
return domain.Broadcast{}, false, nil
}
return s.store.BroadcastByID(ctx, id)
}
// RunSendCycle drains up to limit pending recipient rows, sending each from
// domain.OfficialSystemUserID. One recipient's failure (blocked account,
// deleted account, transient error) never blocks the rest of the batch.
func (s *Service) RunSendCycle(ctx context.Context, limit int) (sent int, err error) {
if s == nil || !s.Ready() {
return 0, nil
}
pending, err := s.store.PendingBroadcastRecipients(ctx, limit)
if err != nil {
return 0, err
}
for _, recipient := range pending {
if err := ctx.Err(); err != nil {
return sent, err
}
_, sendErr := s.messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
SenderUserID: domain.OfficialSystemUserID,
RecipientUserID: recipient.UserID,
// A stable id derived from (broadcast, recipient) makes reprocessing
// this exact row idempotent at the store layer's random_id dedup,
// instead of risking a duplicate message if this worker crashes
// between sending and marking the row delivered.
RandomID: stableBroadcastRandomID(recipient.BroadcastID, recipient.UserID),
Message: recipient.Message,
})
if sendErr != nil {
if markErr := s.store.MarkBroadcastRecipientFailed(ctx, recipient.RecipientID, sendErr.Error()); markErr != nil {
s.log.Warn("mark broadcast recipient failed",
zap.Int64("recipient_id", recipient.RecipientID), zap.Error(markErr))
}
continue
}
if markErr := s.store.MarkBroadcastRecipientSent(ctx, recipient.RecipientID); markErr != nil {
s.log.Warn("mark broadcast recipient sent",
zap.Int64("recipient_id", recipient.RecipientID), zap.Error(markErr))
continue
}
sent++
}
return sent, nil
}
// stableBroadcastRandomID derives a random_id from (broadcastID, userID) so
// re-processing the same recipient row (after a crash, before it was marked
// delivered) resolves to the same send instead of a duplicate message.
func stableBroadcastRandomID(broadcastID, userID int64) int64 {
h := fnv.New64a()
_, _ = h.Write([]byte(strconv.FormatInt(broadcastID, 10) + ":" + strconv.FormatInt(userID, 10)))
v := int64(h.Sum64())
if v == 0 {
v = 1
}
return v
}

View file

@ -0,0 +1,131 @@
package broadcast
import (
"context"
"errors"
"testing"
"telesrv/internal/domain"
"telesrv/internal/store/memory"
)
type fakeSender struct {
sent []domain.SendPrivateTextRequest
failFor map[int64]bool // fail every send to this recipient user id
}
func (f *fakeSender) SendPrivateText(_ context.Context, req domain.SendPrivateTextRequest) (domain.SendPrivateTextResult, error) {
if f.failFor[req.RecipientUserID] {
return domain.SendPrivateTextResult{}, errors.New("simulated send failure")
}
f.sent = append(f.sent, req)
return domain.SendPrivateTextResult{}, nil
}
func TestCreateValidatesInput(t *testing.T) {
svc := NewService(memory.NewBroadcastStore(), WithMessageSender(&fakeSender{}))
ctx := context.Background()
if _, err := svc.Create(ctx, " ", domain.BroadcastTargetAll, []int64{1}, "admin"); !errors.Is(err, domain.ErrBroadcastMessageEmpty) {
t.Fatalf("empty message: err = %v, want ErrBroadcastMessageEmpty", err)
}
if _, err := svc.Create(ctx, "hi", domain.BroadcastTargetMode("bogus"), []int64{1}, "admin"); !errors.Is(err, domain.ErrBroadcastInvalid) {
t.Fatalf("bad target mode: err = %v, want ErrBroadcastInvalid", err)
}
if _, err := svc.Create(ctx, "hi", domain.BroadcastTargetSelected, nil, "admin"); !errors.Is(err, domain.ErrBroadcastNoRecipients) {
t.Fatalf("no recipients: err = %v, want ErrBroadcastNoRecipients", err)
}
created, err := svc.Create(ctx, " News! ", domain.BroadcastTargetSelected, []int64{10, 20, 20}, "admin")
if err != nil {
t.Fatalf("Create: %v", err)
}
if created.Message != "News!" {
t.Fatalf("Message = %q, want trimmed %q", created.Message, "News!")
}
// The duplicate recipient (20 twice) collapses to one row.
if created.TotalCount != 2 {
t.Fatalf("TotalCount = %d, want 2 (duplicate recipient collapsed)", created.TotalCount)
}
}
func TestRunSendCycleDeliversAndCounts(t *testing.T) {
store := memory.NewBroadcastStore()
sender := &fakeSender{}
svc := NewService(store, WithMessageSender(sender))
ctx := context.Background()
created, err := svc.Create(ctx, "Update available", domain.BroadcastTargetSelected, []int64{101, 102, 103}, "admin")
if err != nil {
t.Fatalf("Create: %v", err)
}
sent, err := svc.RunSendCycle(ctx, 10)
if err != nil {
t.Fatalf("RunSendCycle: %v", err)
}
if sent != 3 {
t.Fatalf("sent = %d, want 3", sent)
}
if len(sender.sent) != 3 {
t.Fatalf("sender received %d sends, want 3", len(sender.sent))
}
for _, req := range sender.sent {
if req.SenderUserID != domain.OfficialSystemUserID {
t.Fatalf("SenderUserID = %d, want OfficialSystemUserID (%d)", req.SenderUserID, domain.OfficialSystemUserID)
}
if req.Message != "Update available" {
t.Fatalf("Message = %q, want %q", req.Message, "Update available")
}
}
got, found, err := svc.Get(ctx, created.ID)
if err != nil || !found {
t.Fatalf("Get: found=%v err=%v", found, err)
}
if got.SentCount != 3 || got.FailedCount != 0 {
t.Fatalf("counts = sent:%d failed:%d, want sent:3 failed:0", got.SentCount, got.FailedCount)
}
// A second cycle finds nothing left pending.
sent, err = svc.RunSendCycle(ctx, 10)
if err != nil {
t.Fatalf("RunSendCycle (second): %v", err)
}
if sent != 0 {
t.Fatalf("second cycle sent = %d, want 0 (nothing pending)", sent)
}
}
func TestRunSendCycleRetriesThenTerminatesFailures(t *testing.T) {
store := memory.NewBroadcastStore()
sender := &fakeSender{failFor: map[int64]bool{999: true}}
svc := NewService(store, WithMessageSender(sender))
ctx := context.Background()
if _, err := svc.Create(ctx, "will fail", domain.BroadcastTargetSelected, []int64{999}, "admin"); err != nil {
t.Fatalf("Create: %v", err)
}
// Run one cycle per attempt, up to the cap; the row must stay pending
// (retried) below the cap and become terminal at it.
for i := 0; i < domain.MaxBroadcastRecipientAttempts; i++ {
sent, err := svc.RunSendCycle(ctx, 10)
if err != nil {
t.Fatalf("RunSendCycle attempt %d: %v", i+1, err)
}
if sent != 0 {
t.Fatalf("attempt %d: sent = %d, want 0 (always fails)", i+1, sent)
}
}
// One more cycle: the row is now terminal ('failed'), so PendingBroadcastRecipients
// must not return it, and RunSendCycle finds nothing left to attempt.
pending, err := store.PendingBroadcastRecipients(ctx, 10)
if err != nil {
t.Fatalf("PendingBroadcastRecipients: %v", err)
}
if len(pending) != 0 {
t.Fatalf("pending = %+v, want empty (recipient should be terminally failed)", pending)
}
}

View file

@ -0,0 +1,84 @@
package broadcast
import (
"context"
"time"
"go.uber.org/zap"
)
// defaultInterval/defaultBatch match the shipped
// TELESRV_BROADCAST_WORKER_INTERVAL/_BATCH defaults.
const (
defaultInterval = 3 * time.Second
defaultBatch = 50
)
// Worker drains the broadcast delivery outbox.
//
// A broadcast is created together with its recipient snapshot, never with the
// sends themselves: an admin creating a broadcast for every user must not
// wait on however long that takes. Delivery is therefore a separate,
// retrying cycle over durable rows, and this worker is only its cadence.
type Worker struct {
service *Service
logger *zap.Logger
interval time.Duration
batch int
}
// NewWorker creates the periodic delivery worker. Non-positive
// interval/batch fall back to the shipped defaults.
func NewWorker(service *Service, logger *zap.Logger, interval time.Duration, batch int) *Worker {
if logger == nil {
logger = zap.NewNop()
}
if interval <= 0 {
interval = defaultInterval
}
if batch <= 0 {
batch = defaultBatch
}
return &Worker{service: service, logger: logger, interval: interval, batch: batch}
}
// Run delivers one batch immediately and then on every tick until ctx is
// done. A not-ready service (missing store/sender) exits immediately with
// one explicit log line instead of ticking forever over a no-op.
func (w *Worker) Run(ctx context.Context) {
if w == nil {
return
}
if !w.service.Ready() {
w.logger.Info("broadcast delivery worker disabled: not configured")
return
}
w.runOnce(ctx)
ticker := time.NewTicker(w.interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
w.runOnce(ctx)
}
}
}
func (w *Worker) runOnce(ctx context.Context) {
if w == nil || w.service == nil {
return
}
sent, err := w.service.RunSendCycle(ctx, w.batch)
if err != nil {
if ctx.Err() != nil {
return
}
w.logger.Warn("broadcast delivery cycle failed", zap.Int("sent", sent), zap.Int("batch", w.batch), zap.Error(err))
return
}
if sent > 0 {
w.logger.Info("broadcast delivery cycle completed", zap.Int("sent", sent), zap.Int("batch", w.batch))
}
}

View file

@ -548,6 +548,14 @@ type Config struct {
// verifier bots. 0 for either disables the budget.
BotVerificationRequestRateLimit int
BotVerificationRequestRateWindow time.Duration
// BroadcastWorkerInterval / BroadcastWorkerBatch drive the system-broadcast
// delivery worker (internal/app/broadcast): an admin-created broadcast is
// snapshotted into a durable per-recipient outbox immediately, and this
// worker drains it in batches, so sending to thousands of users never blocks
// the admin action itself.
BroadcastWorkerInterval time.Duration
BroadcastWorkerBatch int
// HideThirdPartyVerification hides third-party bot verification instead of
// removing it: the admin panel drops its "Third-party marks" nav entry and
// refuses every botverification.* route with 404 (regardless of session
@ -946,6 +954,8 @@ func Load() (Config, error) {
// verifier bots, and filing with a second company is not a retry of the first.
BotVerificationRequestRateLimit: envIntOr("TELESRV_BOT_VERIFICATION_REQUEST_RATE_LIMIT", 5),
BotVerificationRequestRateWindow: envDurationOr("TELESRV_BOT_VERIFICATION_REQUEST_RATE_WINDOW", 24*time.Hour),
BroadcastWorkerInterval: envDurationOr("TELESRV_BROADCAST_WORKER_INTERVAL", 3*time.Second),
BroadcastWorkerBatch: envIntOr("TELESRV_BROADCAST_WORKER_BATCH", 50),
HideThirdPartyVerification: envBoolOr("TELESRV_HIDE_THIRD_PARTY_VERIFICATION", true),
GroupCallCheckTTL: envDurationOr("TELESRV_GROUPCALL_CHECK_TTL", 45*time.Second),

View file

@ -0,0 +1,70 @@
package domain
import (
"errors"
"time"
)
// BroadcastTargetMode selects who a broadcast's recipients are.
type BroadcastTargetMode string
const (
// BroadcastTargetAll snapshots every non-bot, non-system account at
// creation time (mirrors the exclusion cmd/telesrv-admin's CountAccounts
// already applies: real users only, not @BotFather/@Stickers/@ChatBot/777000
// itself).
BroadcastTargetAll BroadcastTargetMode = "all"
// BroadcastTargetSelected sends only to the operator-picked user list
// carried on the create request.
BroadcastTargetSelected BroadcastTargetMode = "selected"
)
// BroadcastRecipientStatus is one recipient row's delivery state.
type BroadcastRecipientStatus string
const (
BroadcastRecipientPending BroadcastRecipientStatus = "pending"
BroadcastRecipientSent BroadcastRecipientStatus = "sent"
// BroadcastRecipientFailed is terminal: MaxBroadcastRecipientAttempts was
// reached, so the worker stops retrying this row. A blocked or deleted
// recipient must not spin forever alongside everyone else's real deliveries.
BroadcastRecipientFailed BroadcastRecipientStatus = "failed"
)
// MaxBroadcastRecipientAttempts bounds retries per recipient before the
// worker gives up and marks the row permanently failed.
const MaxBroadcastRecipientAttempts = 5
// Broadcast is one admin-triggered system message campaign, sent from
// OfficialSystemUserID (777000) to every recipient snapshotted into
// broadcast_recipients at creation time. SentCount/FailedCount are derived
// from the recipient rows at read time, not stored, so they can never drift.
type Broadcast struct {
ID int64
Message string
TargetMode BroadcastTargetMode
TotalCount int
SentCount int
FailedCount int
CreatedBy string
CreatedAt time.Time
}
// BroadcastRecipient is one durable outbox row: one user's delivery state
// for one broadcast.
type BroadcastRecipient struct {
ID int64
BroadcastID int64
UserID int64
Status BroadcastRecipientStatus
Attempts int
LastError string
SentAt *time.Time
}
var (
ErrBroadcastInvalid = errors.New("broadcast invalid")
ErrBroadcastMessageEmpty = errors.New("broadcast message is empty")
ErrBroadcastNoRecipients = errors.New("broadcast has no recipients")
ErrBroadcastNotFound = errors.New("broadcast not found")
)

View file

@ -0,0 +1,44 @@
package store
import (
"context"
"telesrv/internal/domain"
)
// BroadcastStore persists system broadcast campaigns and their durable
// per-recipient delivery outbox.
type BroadcastStore interface {
// CreateBroadcast inserts the broadcast row and one pending recipient row
// per id in recipientUserIDs, in a single transaction: a broadcast with
// zero recipients (an empty "selected" list, or an "all" snapshot taken
// when there happen to be no eligible users) is rejected with
// domain.ErrBroadcastNoRecipients rather than created empty.
CreateBroadcast(ctx context.Context, message string, targetMode domain.BroadcastTargetMode, recipientUserIDs []int64, createdBy string) (domain.Broadcast, error)
// PendingBroadcastRecipients returns undelivered outbox rows across every
// broadcast, oldest first, each carrying its broadcast's message text so
// the worker can send without a second round trip per row.
PendingBroadcastRecipients(ctx context.Context, limit int) ([]PendingBroadcastRecipient, error)
// MarkBroadcastRecipientSent closes a recipient row as delivered.
MarkBroadcastRecipientSent(ctx context.Context, recipientID int64) error
// MarkBroadcastRecipientFailed records a failed attempt. The row stays
// 'pending' (retried on the next cycle) until attempts reaches
// domain.MaxBroadcastRecipientAttempts, at which point it becomes the
// terminal 'failed' status.
MarkBroadcastRecipientFailed(ctx context.Context, recipientID int64, reason string) error
// ListBroadcasts pages broadcasts newest-first, each with sent/failed
// counts derived live from its recipient rows.
ListBroadcasts(ctx context.Context, beforeID int64, limit int) ([]domain.Broadcast, bool, error)
// BroadcastByID returns one broadcast with derived counts.
BroadcastByID(ctx context.Context, id int64) (domain.Broadcast, bool, error)
}
// PendingBroadcastRecipient is one undelivered outbox row, joined with its
// broadcast's message text.
type PendingBroadcastRecipient struct {
RecipientID int64
BroadcastID int64
UserID int64
Attempts int
Message string
}

View file

@ -0,0 +1,188 @@
package memory
import (
"context"
"sort"
"sync"
"time"
"telesrv/internal/domain"
"telesrv/internal/store"
)
// BroadcastStore is the in-memory implementation of store.BroadcastStore,
// used by admin/app unit tests.
type BroadcastStore struct {
mu sync.Mutex
broadcasts map[int64]domain.Broadcast
recipients map[int64]*memBroadcastRecipient
nextBID int64
nextRID int64
}
type memBroadcastRecipient struct {
domain.BroadcastRecipient
message string
}
func NewBroadcastStore() *BroadcastStore {
return &BroadcastStore{
broadcasts: make(map[int64]domain.Broadcast),
recipients: make(map[int64]*memBroadcastRecipient),
}
}
var _ store.BroadcastStore = (*BroadcastStore)(nil)
func (s *BroadcastStore) CreateBroadcast(_ context.Context, message string, targetMode domain.BroadcastTargetMode, recipientUserIDs []int64, createdBy string) (domain.Broadcast, error) {
if len(recipientUserIDs) == 0 {
return domain.Broadcast{}, domain.ErrBroadcastNoRecipients
}
s.mu.Lock()
defer s.mu.Unlock()
s.nextBID++
b := domain.Broadcast{
ID: s.nextBID,
Message: message,
TargetMode: targetMode,
CreatedBy: createdBy,
CreatedAt: time.Now().UTC(),
}
seen := make(map[int64]bool, len(recipientUserIDs))
for _, userID := range recipientUserIDs {
if seen[userID] {
continue
}
seen[userID] = true
s.nextRID++
s.recipients[s.nextRID] = &memBroadcastRecipient{
BroadcastRecipient: domain.BroadcastRecipient{
ID: s.nextRID,
BroadcastID: b.ID,
UserID: userID,
Status: domain.BroadcastRecipientPending,
},
message: message,
}
b.TotalCount++
}
s.broadcasts[b.ID] = b
return b, nil
}
func (s *BroadcastStore) PendingBroadcastRecipients(_ context.Context, limit int) ([]store.PendingBroadcastRecipient, error) {
if limit <= 0 || limit > 200 {
limit = 50
}
s.mu.Lock()
defer s.mu.Unlock()
// Iteration order over a map is unspecified; sort by recipient id (assigned
// in creation order) so this matches the postgres backend's "oldest first".
ids := make([]int64, 0, len(s.recipients))
for id, r := range s.recipients {
if r.Status == domain.BroadcastRecipientPending {
ids = append(ids, id)
}
}
sortInt64s(ids)
out := make([]store.PendingBroadcastRecipient, 0, limit)
for _, id := range ids {
if len(out) >= limit {
break
}
r := s.recipients[id]
out = append(out, store.PendingBroadcastRecipient{
RecipientID: r.ID, BroadcastID: r.BroadcastID, UserID: r.UserID, Attempts: r.Attempts, Message: r.message,
})
}
return out, nil
}
func (s *BroadcastStore) MarkBroadcastRecipientSent(_ context.Context, recipientID int64) error {
s.mu.Lock()
defer s.mu.Unlock()
r, ok := s.recipients[recipientID]
if !ok || r.Status != domain.BroadcastRecipientPending {
return nil
}
r.Status = domain.BroadcastRecipientSent
now := time.Now().UTC()
r.SentAt = &now
r.LastError = ""
return nil
}
func (s *BroadcastStore) MarkBroadcastRecipientFailed(_ context.Context, recipientID int64, reason string) error {
s.mu.Lock()
defer s.mu.Unlock()
r, ok := s.recipients[recipientID]
if !ok || r.Status != domain.BroadcastRecipientPending {
return nil
}
r.Attempts++
r.LastError = reason
if r.Attempts >= domain.MaxBroadcastRecipientAttempts {
r.Status = domain.BroadcastRecipientFailed
}
return nil
}
func (s *BroadcastStore) countsFor(broadcastID int64) (sent, failed int) {
for _, r := range s.recipients {
if r.BroadcastID != broadcastID {
continue
}
switch r.Status {
case domain.BroadcastRecipientSent:
sent++
case domain.BroadcastRecipientFailed:
failed++
}
}
return sent, failed
}
func (s *BroadcastStore) ListBroadcasts(_ context.Context, beforeID int64, limit int) ([]domain.Broadcast, bool, error) {
if limit <= 0 || limit > 200 {
limit = 50
}
s.mu.Lock()
defer s.mu.Unlock()
ids := make([]int64, 0, len(s.broadcasts))
for id := range s.broadcasts {
if beforeID == 0 || id < beforeID {
ids = append(ids, id)
}
}
sortInt64sDesc(ids)
hasMore := len(ids) > limit
if hasMore {
ids = ids[:limit]
}
out := make([]domain.Broadcast, 0, len(ids))
for _, id := range ids {
b := s.broadcasts[id]
b.SentCount, b.FailedCount = s.countsFor(id)
out = append(out, b)
}
return out, hasMore, nil
}
func (s *BroadcastStore) BroadcastByID(_ context.Context, id int64) (domain.Broadcast, bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
b, ok := s.broadcasts[id]
if !ok {
return domain.Broadcast{}, false, nil
}
b.SentCount, b.FailedCount = s.countsFor(id)
return b, true, nil
}
func sortInt64s(v []int64) {
sort.Slice(v, func(i, j int) bool { return v[i] < v[j] })
}
func sortInt64sDesc(v []int64) {
sort.Slice(v, func(i, j int) bool { return v[i] > v[j] })
}

View file

@ -0,0 +1,193 @@
package postgres
import (
"context"
"fmt"
"github.com/jackc/pgx/v5"
"telesrv/internal/domain"
"telesrv/internal/store"
"telesrv/internal/store/postgres/sqlcgen"
)
// BroadcastStore persists system broadcast campaigns (see
// deploy/migrations/20260714003131_system_broadcasts.up.sql).
type BroadcastStore struct {
db sqlcgen.DBTX
}
// NewBroadcastStore builds the store on a pgx pool or transaction.
func NewBroadcastStore(db sqlcgen.DBTX) *BroadcastStore {
return &BroadcastStore{db: db}
}
var _ store.BroadcastStore = (*BroadcastStore)(nil)
// CreateBroadcast inserts the broadcast row and one pending recipient row per
// id, deduplicating recipientUserIDs (a "selected" list built by hand in the
// panel could otherwise carry a repeat) via ON CONFLICT DO NOTHING against
// the (broadcast_id, user_id) unique constraint.
func (s *BroadcastStore) CreateBroadcast(ctx context.Context, message string, targetMode domain.BroadcastTargetMode, recipientUserIDs []int64, createdBy string) (domain.Broadcast, error) {
if len(recipientUserIDs) == 0 {
return domain.Broadcast{}, domain.ErrBroadcastNoRecipients
}
var out domain.Broadcast
err := withTx(ctx, s.db, "create broadcast", func(tx pgx.Tx) error {
if err := tx.QueryRow(ctx, `
INSERT INTO broadcasts (message, target_mode, total_count, created_by)
VALUES ($1, $2, $3, $4)
RETURNING id, message, target_mode, total_count, created_by, created_at`,
message, string(targetMode), len(recipientUserIDs), createdBy,
).Scan(&out.ID, &out.Message, &out.TargetMode, &out.TotalCount, &out.CreatedBy, &out.CreatedAt); err != nil {
return fmt.Errorf("insert broadcast: %w", err)
}
batch := &pgx.Batch{}
for _, userID := range recipientUserIDs {
batch.Queue(`
INSERT INTO broadcast_recipients (broadcast_id, user_id)
VALUES ($1, $2)
ON CONFLICT (broadcast_id, user_id) DO NOTHING`, out.ID, userID)
}
results := tx.SendBatch(ctx, batch)
defer results.Close()
for range recipientUserIDs {
if _, err := results.Exec(); err != nil {
return fmt.Errorf("insert broadcast recipient: %w", err)
}
}
return nil
})
if err != nil {
return domain.Broadcast{}, err
}
return out, nil
}
// PendingBroadcastRecipients returns undelivered outbox rows, oldest first,
// each carrying its broadcast's message text.
func (s *BroadcastStore) PendingBroadcastRecipients(ctx context.Context, limit int) ([]store.PendingBroadcastRecipient, error) {
if limit <= 0 || limit > 200 {
limit = 50
}
rows, err := s.db.Query(ctx, `
SELECT r.id, r.broadcast_id, r.user_id, r.attempts, b.message
FROM broadcast_recipients r
JOIN broadcasts b ON b.id = r.broadcast_id
WHERE r.status = 'pending'
ORDER BY r.id
LIMIT $1`, limit)
if err != nil {
return nil, fmt.Errorf("list pending broadcast recipients: %w", err)
}
defer rows.Close()
out := make([]store.PendingBroadcastRecipient, 0, limit)
for rows.Next() {
var item store.PendingBroadcastRecipient
if err := rows.Scan(&item.RecipientID, &item.BroadcastID, &item.UserID, &item.Attempts, &item.Message); err != nil {
return nil, fmt.Errorf("scan pending broadcast recipient: %w", err)
}
out = append(out, item)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate pending broadcast recipients: %w", err)
}
return out, nil
}
// MarkBroadcastRecipientSent closes a recipient row as delivered. Closing an
// already-closed row is a no-op: the outbox is exactly-once, not
// at-least-once.
func (s *BroadcastStore) MarkBroadcastRecipientSent(ctx context.Context, recipientID int64) error {
if _, err := s.db.Exec(ctx, `
UPDATE broadcast_recipients
SET status = 'sent', sent_at = now(), last_error = ''
WHERE id = $1 AND status = 'pending'`, recipientID); err != nil {
return fmt.Errorf("mark broadcast recipient sent: %w", err)
}
return nil
}
// MarkBroadcastRecipientFailed records a failed delivery attempt. The row
// stays 'pending' (retried on the next cycle) until attempts reaches
// domain.MaxBroadcastRecipientAttempts, at which point it becomes the
// terminal 'failed' status so a permanently blocked/deleted recipient
// doesn't spin forever alongside real deliveries.
func (s *BroadcastStore) MarkBroadcastRecipientFailed(ctx context.Context, recipientID int64, reason string) error {
if len(reason) > 500 {
reason = reason[:500]
}
if _, err := s.db.Exec(ctx, `
UPDATE broadcast_recipients
SET attempts = attempts + 1,
last_error = $2,
status = CASE WHEN attempts + 1 >= $3 THEN 'failed' ELSE 'pending' END
WHERE id = $1 AND status = 'pending'`, recipientID, reason, domain.MaxBroadcastRecipientAttempts); err != nil {
return fmt.Errorf("mark broadcast recipient failed: %w", err)
}
return nil
}
const broadcastSelectColumns = `
b.id, b.message, b.target_mode, b.total_count, b.created_by, b.created_at,
count(*) FILTER (WHERE r.status = 'sent')::int AS sent_count,
count(*) FILTER (WHERE r.status = 'failed')::int AS failed_count`
func scanBroadcastRow(row interface{ Scan(...any) error }, item *domain.Broadcast) error {
return row.Scan(&item.ID, &item.Message, &item.TargetMode, &item.TotalCount, &item.CreatedBy, &item.CreatedAt,
&item.SentCount, &item.FailedCount)
}
// ListBroadcasts pages broadcasts newest-first, each with sent/failed counts
// derived live from its recipient rows (never stored, so they can't drift).
func (s *BroadcastStore) ListBroadcasts(ctx context.Context, beforeID int64, limit int) ([]domain.Broadcast, bool, error) {
if limit <= 0 || limit > 200 {
limit = 50
}
rows, err := s.db.Query(ctx, `
SELECT `+broadcastSelectColumns+`
FROM broadcasts b
LEFT JOIN broadcast_recipients r ON r.broadcast_id = b.id
WHERE $1::bigint = 0 OR b.id < $1
GROUP BY b.id
ORDER BY b.id DESC
LIMIT $2`, beforeID, limit+1)
if err != nil {
return nil, false, fmt.Errorf("list broadcasts: %w", err)
}
defer rows.Close()
out := make([]domain.Broadcast, 0, limit+1)
for rows.Next() {
var item domain.Broadcast
if err := scanBroadcastRow(rows, &item); err != nil {
return nil, false, fmt.Errorf("scan broadcast: %w", err)
}
out = append(out, item)
}
if err := rows.Err(); err != nil {
return nil, false, fmt.Errorf("iterate broadcasts: %w", err)
}
hasMore := len(out) > limit
if hasMore {
out = out[:limit]
}
return out, hasMore, nil
}
// BroadcastByID returns one broadcast with derived counts.
func (s *BroadcastStore) BroadcastByID(ctx context.Context, id int64) (domain.Broadcast, bool, error) {
var item domain.Broadcast
err := scanBroadcastRow(s.db.QueryRow(ctx, `
SELECT `+broadcastSelectColumns+`
FROM broadcasts b
LEFT JOIN broadcast_recipients r ON r.broadcast_id = b.id
WHERE b.id = $1
GROUP BY b.id`, id), &item)
if err != nil {
if err == pgx.ErrNoRows {
return domain.Broadcast{}, false, nil
}
return domain.Broadcast{}, false, fmt.Errorf("get broadcast: %w", err)
}
return item, true, nil
}