This commit is contained in:
onysd 2026-09-01 12:50:18 +03:00
parent 21a0856587
commit e8dc967e6a
26 changed files with 1373 additions and 481 deletions

View file

@ -954,27 +954,31 @@ ORDER BY g.last_active_at DESC, g.device_model, g.system_version, g.platform, g.
return groups, hasMore, nil
}
// BroadcastRow is one system-broadcast campaign, with sent/failed counts
// derived live from broadcast_recipients (never stored, so they can't drift).
// BroadcastRow is one system-broadcast campaign. SentCount/FailedCount/
// MaterializedCount are maintained incrementally by the delivery worker as
// it closes out each recipient row (see internal/app/broadcast); for an
// "all"-mode campaign still enumerating, TargetCount grows until
// EnumerationDone.
type BroadcastRow struct {
ID int64
Message string
TargetMode string
TotalCount int
SentCount int
FailedCount int
CreatedBy string
CreatedAt time.Time
ID int64
Message string
TargetMode string
TargetCount int64
MaterializedCount int64
SentCount int64
FailedCount int64
EnumerationDone bool
CreatedBy string
CreatedAt time.Time
}
const broadcastRowColumns = `
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`
b.id, b.message, b.target_mode, b.target_count, b.materialized_count,
b.sent_count, b.failed_count, b.enumeration_done, b.created_by, b.created_at`
func scanBroadcastRow(row interface{ Scan(...any) error }, item *BroadcastRow) error {
return row.Scan(&item.ID, &item.Message, &item.TargetMode, &item.TotalCount, &item.CreatedBy, &item.CreatedAt,
&item.SentCount, &item.FailedCount)
return row.Scan(&item.ID, &item.Message, &item.TargetMode, &item.TargetCount, &item.MaterializedCount,
&item.SentCount, &item.FailedCount, &item.EnumerationDone, &item.CreatedBy, &item.CreatedAt)
}
// ListBroadcasts pages campaigns newest-first.
@ -985,9 +989,7 @@ func (s *readStore) ListBroadcasts(ctx context.Context, beforeID int64, limit in
rows, err := s.pool.Query(ctx, `
SELECT `+broadcastRowColumns+`
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 {

View file

@ -17,6 +17,8 @@ import (
"strings"
"time"
"github.com/iamxvbaba/td/tg"
"telesrv/internal/admin"
"telesrv/internal/domain"
"telesrv/internal/hoststats"
@ -303,6 +305,10 @@ func (s *server) handleSession(w http.ResponseWriter, r *http.Request) {
// tells "the old admin process died and a new one answered" apart
// from "the old one is just slow to respond".
"boot_id": bootID,
// api_layer is the MTProto TL schema layer this server binary speaks
// (tg.Layer), shown in the sidebar footer above the build/commit line
// so an operator can tell at a glance which protocol layer is live.
"api_layer": tg.Layer,
// build is this admin binary's own commit -- shown under "Version"
// in the sidebar footer so an operator can tell at a glance which
// build is actually running, independent of the app version string.
@ -936,10 +942,13 @@ type createBroadcastAPIRequest struct {
UserIDs []int64 `json:"user_ids,omitempty"`
}
// handleCreateBroadcastAPI resolves "all users" into an explicit id list
// before forwarding to the admin API: the admin service always receives an
// already-resolved recipient list, never "every user" as a live concept it
// would have to know how to enumerate itself.
// handleCreateBroadcastAPI forwards a broadcast create straight to the admin
// API. "all" mode is no longer pre-resolved into an explicit id list here:
// the admin service snapshots the current eligible user set itself and the
// broadcast worker enumerates it incrementally, so "every user" never has
// to cross this boundary (or the one after it) as a potentially huge id
// slice. Only "selected" mode carries UserIDs, already an operator-picked
// list bounded by domain.MaxBroadcastSelectedRecipients.
func (s *server) handleCreateBroadcastAPI(w http.ResponseWriter, r *http.Request) {
var body createBroadcastAPIRequest
if !decodeAction(w, r, &body) {
@ -947,16 +956,7 @@ func (s *server) handleCreateBroadcastAPI(w http.ResponseWriter, r *http.Request
}
userIDs := body.UserIDs
if body.TargetMode == "all" {
if s.read == nil {
writeAPIError(w, http.StatusServiceUnavailable, "read store is not configured")
return
}
all, err := s.read.ListAllAccountIDs(r.Context())
if err != nil {
writeAPIError(w, http.StatusInternalServerError, err.Error())
return
}
userIDs = all
userIDs = nil
}
req := admin.CreateBroadcastRequest{
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "create-broadcast"),

View file

@ -23,7 +23,7 @@
})();
</script>
<script type="module" crossorigin src="/assets/index-CwTwvGWj.js"></script>
<script type="module" crossorigin src="/assets/index-TfcI68oK.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-0MvM-hpw.css">
</head>
<body>

View file

@ -42,7 +42,7 @@ export function App() {
return (
<PermissionsProvider permissions={session.permissions ?? []} hideThirdPartyVerification={session.hide_third_party_verification ?? true}>
<Shell actor={session.actor} build={session.build} route={route} navigate={navigate} onLogout={() => setSession(null)}>
<Shell actor={session.actor} apiLayer={session.api_layer} build={session.build} route={route} navigate={navigate} onLogout={() => setSession(null)}>
<Routes route={route} navigate={navigate} />
</Shell>
</PermissionsProvider>

View file

@ -8,9 +8,11 @@ import { MultiUserPicker } from "./EntityPicker";
type TargetMode = "all" | "selected";
// CreateBroadcastModal composes the message and target list, then hands off to
// ActionButton for the usual dry-run/confirm flow. "All users" is resolved to an
// explicit id list server-side (cmd/telesrv-admin/server.go), not here -- the
// picker only ever deals with an actual, visible list of accounts.
// ActionButton for the usual dry-run/confirm flow. "All users" is never
// resolved into an id list at all -- the admin service snapshots the
// current eligible user set itself and a background worker enumerates it
// incrementally, so this only ever sends user_ids for "selected" mode,
// where the picker deals with an actual, visible list of accounts.
export function CreateBroadcastModal({ onClose, onCreated }: { onClose: () => void; onCreated: () => void }) {
const [message, setMessage] = useState("");
const [targetMode, setTargetMode] = useState<TargetMode>("all");

View file

@ -41,6 +41,7 @@ export function BootScreen() {
export function Shell({
actor,
apiLayer,
build,
route,
navigate,
@ -48,6 +49,7 @@ export function Shell({
children
}: {
actor: string;
apiLayer?: number;
build?: { commit: string; short_commit: string; dirty: boolean; build_time: string };
route: RouteState;
navigate: Navigate;
@ -178,6 +180,9 @@ export function Shell({
</nav>
<div className="sidebar-status">
<span className="sidebar-label">{"Version: O7"}</span>
{typeof apiLayer === "number" && (
<span className="sidebar-label sidebar-api-layer">{`API layer: ${apiLayer}`}</span>
)}
{build?.short_commit && (
<span className="sidebar-label sidebar-build" title={build.commit + (build.dirty ? " (uncommitted changes)" : "")}>
{`Build: ${build.short_commit}${build.dirty ? "+" : ""}`}

View file

@ -68,7 +68,7 @@ export function BroadcastsPage() {
}, []);
const rows = data?.rows ?? [];
const inFlight = rows.filter((row) => row.SentCount + row.FailedCount < row.TotalCount).length;
const inFlight = rows.filter((row) => !row.EnumerationDone || row.SentCount + row.FailedCount < row.TargetCount).length;
const canGoPrev = history.length > 0 && !busy;
const canGoNext = Boolean(data?.has_more) && !busy;
@ -110,7 +110,7 @@ export function BroadcastsPage() {
<tbody>
{rows.map((row) => {
const delivered = row.SentCount + row.FailedCount;
const done = row.TotalCount > 0 && delivered >= row.TotalCount;
const done = row.EnumerationDone && row.TargetCount > 0 && delivered >= row.TargetCount;
return (
<tr key={row.ID}>
<td className="mono">{row.ID}</td>
@ -118,7 +118,7 @@ export function BroadcastsPage() {
<td>{row.TargetMode === "all" ? <Badge tone="warn">{"All users"}</Badge> : <Badge>{"Selected"}</Badge>}</td>
<td>{row.SentCount}</td>
<td>{row.FailedCount > 0 ? <Badge tone="danger">{row.FailedCount}</Badge> : row.FailedCount}</td>
<td>{row.TotalCount}</td>
<td>{row.TargetCount}</td>
<td>{row.CreatedBy || "-"}</td>
<td>
{formatDate(row.CreatedAt)}

View file

@ -584,6 +584,9 @@ export type AdminSession = {
// comment. Used by Server Settings' Restart/Update flow to detect a
// genuinely new admin process after asking it to bounce.
boot_id?: string;
// The MTProto TL schema layer this server binary speaks -- shown in the
// sidebar footer above the build/commit line.
api_layer?: number;
// This admin binary's own build -- shown under "Version" in the sidebar
// footer so an operator can tell which build is actually running.
build?: {
@ -794,9 +797,11 @@ export type BroadcastRow = {
ID: number;
Message: string;
TargetMode: string;
TotalCount: number;
TargetCount: number;
MaterializedCount: number;
SentCount: number;
FailedCount: number;
EnumerationDone: boolean;
CreatedBy: string;
CreatedAt: string;
};

View file

@ -1705,8 +1705,12 @@ func run(logger *zap.Logger) error {
// all/selected users) are delivered from the same kind of durable outbox as
// applicant notifications above: an admin creating one for every user must
// not wait on however long sending to all of them takes.
go broadcastapp.NewWorker(broadcastService, logger.Named("broadcast").Named("delivery"),
cfg.BroadcastWorkerInterval, cfg.BroadcastWorkerBatch).Run(ctx)
go broadcastapp.NewWorker(broadcastService, broadcastapp.WorkerConfig{
Interval: cfg.BroadcastWorkerInterval,
Lease: cfg.BroadcastWorkerLease,
MaterializeBatch: cfg.BroadcastWorkerMaterializeBatch,
DeliveryBatch: cfg.BroadcastWorkerBatch,
}, logger.Named("broadcast").Named("delivery")).Run(ctx)
moderationActionOptions := []moderationapp.ActionExecutorOption{
moderationapp.WithAccountDeletionNotifier(router),
}