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

@ -512,6 +512,10 @@ TELESRV_VERIFICATION_NOTIFY_BATCH=50
# Applications one applicant may keep open at once; 0 disables the cap, maximum
# is 50.
TELESRV_VERIFICATION_MAX_ACTIVE_PER_USER=3
# System broadcast delivery worker (messages sent from account 777000). Same
# durable-outbox pattern as the verification notifier above.
TELESRV_BROADCAST_WORKER_INTERVAL=3s
TELESRV_BROADCAST_WORKER_BATCH=50
# Tuning for third-party bot verification; the on/off switch
# (TELESRV_BOT_VERIFICATION_ENABLED) is in the Moderation/Verification/Rating

View file

@ -469,6 +469,31 @@ SELECT count(*) FROM users WHERE NOT is_bot AND id <> ALL($1::bigint[])`,
return n, nil
}
// ListAllAccountIDs resolves a "broadcast to all users" target into an
// explicit id list -- the same real-account exclusion CountAccounts uses
// (no bots, no built-in system accounts), so a broadcast never targets
// @BotFather/@Stickers/@ChatBot or 777000 itself.
func (s *readStore) ListAllAccountIDs(ctx context.Context) ([]int64, error) {
rows, err := s.pool.Query(ctx, `
SELECT id FROM users WHERE NOT is_bot AND id <> ALL($1::bigint[])`, systemAccountIDs)
if err != nil {
return nil, fmt.Errorf("list all account ids: %w", err)
}
defer rows.Close()
out := make([]int64, 0, 256)
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
return nil, fmt.Errorf("scan account id: %w", err)
}
out = append(out, id)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate account ids: %w", err)
}
return out, nil
}
// CountOnlineAccounts counts accounts the live server currently considers
// online. Presence itself lives only in the live server's in-process
// presenceTracker (internal/rpc/presence.go), unreachable from this
@ -896,6 +921,64 @@ 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).
type BroadcastRow struct {
ID int64
Message string
TargetMode string
TotalCount int
SentCount int
FailedCount int
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`
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)
}
// ListBroadcasts pages campaigns newest-first.
func (s *readStore) ListBroadcasts(ctx context.Context, beforeID int64, limit int) ([]BroadcastRow, bool, error) {
if limit <= 0 || limit > 200 {
limit = 50
}
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 {
return nil, false, fmt.Errorf("list broadcasts: %w", err)
}
defer rows.Close()
out := make([]BroadcastRow, 0, limit+1)
for rows.Next() {
var item BroadcastRow
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
}
func (s *readStore) AccountDetail(ctx context.Context, userID int64) (AccountDetail, error) {
var out AccountDetail
err := s.pool.QueryRow(ctx, `

View file

@ -55,6 +55,7 @@ func (s *server) routes() http.Handler {
mux.Handle("GET /api/accounts", s.requireAuthAPI(http.HandlerFunc(s.handleAccountsAPI)))
mux.Handle("GET /api/accounts/stats", s.requireAuthAPI(http.HandlerFunc(s.handleAccountsStatsAPI)))
mux.Handle("GET /api/accounts/shared-devices", s.requireAuthAPI(http.HandlerFunc(s.handleSharedDeviceGroupsAPI)))
mux.Handle("GET /api/broadcasts", s.requireAuthAPI(http.HandlerFunc(s.handleBroadcastsAPI)))
mux.Handle("GET /api/accounts/{id}", s.requireAuthAPI(http.HandlerFunc(s.handleAccountDetailAPI)))
mux.Handle("GET /api/accounts/{id}/avatar", s.requireAuthAPI(http.HandlerFunc(s.handleAccountAvatarAPI)))
mux.Handle("GET /api/channels", s.requireAuthAPI(http.HandlerFunc(s.handleChannelsAPI)))
@ -108,6 +109,7 @@ func (s *server) routes() http.Handler {
mux.Handle("POST /api/actions/set-channel-color", s.requireAuthAPI(http.HandlerFunc(s.handleSetChannelColorAPI)))
mux.Handle("POST /api/actions/set-channel-emoji-status", s.requireAuthAPI(http.HandlerFunc(s.handleSetChannelEmojiStatusAPI)))
mux.Handle("POST /api/actions/create-bot", s.requireAuthAPI(http.HandlerFunc(s.handleCreateBotAPI)))
mux.Handle("POST /api/actions/create-broadcast", s.requireAuthAPI(http.HandlerFunc(s.handleCreateBroadcastAPI)))
mux.Handle("POST /api/actions/delete-bot", s.requireAuthAPI(http.HandlerFunc(s.handleDeleteBotAPI)))
mux.Handle("POST /api/actions/export-bot-token", s.requireAuthAPI(http.HandlerFunc(s.handleExportBotTokenAPI)))
mux.Handle("POST /api/actions/set-channel-verified", s.requireAuthAPI(http.HandlerFunc(s.handleSetChannelVerifiedAPI)))
@ -662,6 +664,36 @@ func (s *server) handleSharedDeviceGroupsAPI(w http.ResponseWriter, r *http.Requ
})
}
func (s *server) handleBroadcastsAPI(w http.ResponseWriter, r *http.Request) {
if s.read == nil {
writeAPIError(w, http.StatusServiceUnavailable, "read store is not configured")
return
}
beforeID, _ := parseInt64(r.URL.Query().Get("before_id"))
limit, _ := parseInt(r.URL.Query().Get("limit"))
rows, hasMore, err := s.read.ListBroadcasts(r.Context(), beforeID, limit)
if err != nil {
writeAPIError(w, http.StatusInternalServerError, err.Error())
return
}
nextBeforeID := int64(0)
if hasMore && len(rows) > 0 {
nextBeforeID = rows[len(rows)-1].ID
}
if limit <= 0 {
limit = accountListDefaultLimit
}
if limit > accountListMaxLimit {
limit = accountListMaxLimit
}
writeJSON(w, http.StatusOK, map[string]any{
"limit": limit,
"rows": rows,
"has_more": hasMore,
"next_before_id": nextBeforeID,
})
}
func (s *server) handleAccountsStatsAPI(w http.ResponseWriter, r *http.Request) {
if s.read == nil {
writeAPIError(w, http.StatusServiceUnavailable, "read store is not configured")
@ -908,6 +940,47 @@ func (s *server) handleCreateBotAPI(w http.ResponseWriter, r *http.Request) {
writeCommandResultAPI(w, result, err)
}
type createBroadcastAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
Message string `json:"message"`
TargetMode string `json:"target_mode"`
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.
func (s *server) handleCreateBroadcastAPI(w http.ResponseWriter, r *http.Request) {
var body createBroadcastAPIRequest
if !decodeAction(w, r, &body) {
return
}
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
}
req := admin.CreateBroadcastRequest{
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "create-broadcast"),
Message: body.Message,
TargetMode: body.TargetMode,
UserIDs: userIDs,
}
result, err := s.callAdminAPI(r.Context(), "/v1/broadcasts/create", req)
writeCommandResultAPI(w, result, err)
}
type deleteBotAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -23,8 +23,8 @@
})();
</script>
<script type="module" crossorigin src="/assets/index-DWg-er34.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Bv1l1T8K.css">
<script type="module" crossorigin src="/assets/index-CS-EAiSc.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-sqNghGhC.css">
</head>
<body>
<div id="root"></div>

View file

@ -13,6 +13,7 @@ import type {
BotListResponse,
BotVerificationCountsResponse,
BotVerifierListResponse,
BroadcastListResponse,
ChannelDetail,
CustomVerificationListResponse,
CustomVerificationRequestDetail,
@ -160,6 +161,7 @@ export const api = {
channels: (params: URLSearchParams) => request<ChannelListResponse>(`/api/channels?${params.toString()}`),
channel: (id: number) => request<ChannelDetail>(`/api/channels/${id}`),
bots: (params: URLSearchParams) => request<BotListResponse>(`/api/bots?${params.toString()}`),
broadcasts: (params: URLSearchParams) => request<BroadcastListResponse>(`/api/broadcasts?${params.toString()}`),
bot: (id: number) => request<BotDetail>(`/api/bots/${id}`),
collectibleUsernames: (params: URLSearchParams) =>
request<CollectibleUsernameListResponse>(`/api/collectible-usernames?${params.toString()}`),

View file

@ -0,0 +1,80 @@
import { Send, X } from "lucide-react";
import { useMemo, useState } from "react";
import { createPortal } from "react-dom";
import type { AccountRow } from "../types";
import { ActionButton } from "./ActionButton";
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.
export function CreateBroadcastModal({ onClose, onCreated }: { onClose: () => void; onCreated: () => void }) {
const [message, setMessage] = useState("");
const [targetMode, setTargetMode] = useState<TargetMode>("all");
const [recipients, setRecipients] = useState<AccountRow[]>([]);
const disabled = useMemo(() => {
if (!message.trim()) return true;
if (targetMode === "selected" && recipients.length === 0) return true;
return false;
}, [message, targetMode, recipients]);
return createPortal(
<div className="modal-backdrop" role="presentation">
<section className="modal command-modal" role="dialog" aria-modal="true" aria-label={"Send broadcast"}>
<div className="modal-head">
<div>
<div className="eyebrow">{"Broadcasts"}</div>
<h2>{"Send broadcast"}</h2>
</div>
<button className="icon-btn" type="button" onClick={onClose} aria-label={"Close"}><X size={15} /></button>
</div>
<div className="command-body">
<p>{"Sends a message from the official system account (777000) to all users or to a chosen list. Delivery happens in the background and may take a few minutes for large audiences."}</p>
<label className="form-field">
<span>{"Message"}</span>
<textarea
value={message}
onChange={(event) => setMessage(event.target.value)}
rows={5}
maxLength={4096}
placeholder={"What's new..."}
/>
</label>
<div className="bot-create-fields">
<label className="duration-field">
<span>{"Target"}</span>
<select value={targetMode} onChange={(event) => setTargetMode(event.target.value as TargetMode)}>
<option value="all">{"All users"}</option>
<option value="selected">{"Selected users"}</option>
</select>
</label>
</div>
{targetMode === "selected" && (
<MultiUserPicker label={"Recipients"} selected={recipients} onChange={setRecipients} />
)}
</div>
<div className="modal-actions">
<button className="btn" type="button" onClick={onClose}>{"Close"}</button>
<ActionButton
label={"Send broadcast"}
icon={<Send size={15} />}
tone="neutral"
path="/api/actions/create-broadcast"
disabled={disabled}
payload={() => ({
message: message.trim(),
target_mode: targetMode,
user_ids: targetMode === "selected" ? recipients.map((row) => row.ID) : undefined
})}
onDone={onCreated}
/>
</div>
</section>
</div>,
document.body
);
}

View file

@ -98,6 +98,119 @@ export function UserPicker({
);
}
// MultiUserPicker is UserPicker's search widget with a running selection
// instead of a single slot -- clicking a result toggles it in or out of the
// list, shown above the search box as removable chips.
export function MultiUserPicker({
label,
selected,
onChange
}: {
label: string;
selected: AccountRow[];
onChange: (rows: AccountRow[]) => void;
}) {
const [query, setQuery] = useState("");
const [rows, setRows] = useState<AccountRow[]>([]);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
async function search() {
setBusy(true);
setError("");
const params = new URLSearchParams({ limit: "20" });
if (query.trim()) {
params.set("q", query.trim());
}
try {
const result = await api.accounts(params);
setRows(result.rows);
} catch (err) {
setError(errorMessage(err));
} finally {
setBusy(false);
}
}
useEffect(() => {
void search();
}, []);
function toggle(row: AccountRow) {
if (selected.some((entry) => entry.ID === row.ID)) {
onChange(selected.filter((entry) => entry.ID !== row.ID));
} else {
onChange([...selected, row]);
}
}
function remove(id: number) {
onChange(selected.filter((entry) => entry.ID !== id));
}
return (
<div className="entity-picker">
<div className="picker-head">
<span>{label}</span>
{selected.length > 0 ? (
<button className="link-button" type="button" onClick={() => onChange([])}>
<X size={13} /> {"Clear all"}
</button>
) : null}
</div>
{selected.length > 0 ? (
<div className="picker-chip-list">
{selected.map((row) => (
<span key={row.ID} className="picker-chip">
{displayName(row)} <span className="mono">{row.ID}</span>
<button type="button" onClick={() => remove(row.ID)} aria-label={`Remove ${row.ID}`}>
<X size={12} />
</button>
</span>
))}
</div>
) : null}
<div className="picker-search">
<Search size={15} />
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
void search();
}
}}
placeholder={"Search user_id / phone / username"}
/>
<button className="btn compact-btn" type="button" onClick={search} disabled={busy}>
{busy ? <Loader2 size={14} className="spin" /> : "Search"}
</button>
</div>
{error && <div className="picker-error">{error}</div>}
<div className="picker-results">
{rows.map((row) => {
const isSelected = selected.some((entry) => entry.ID === row.ID);
return (
<button
key={row.ID}
className={`picker-row ${isSelected ? "selected" : ""}`}
type="button"
onClick={() => toggle(row)}
>
<span className="mono">{row.ID}</span>
<strong>{displayName(row)}</strong>
<span>{displayUsername(row.Username) || displayPhone(row.Phone) || "-"}</span>
{isSelected ? <Check size={15} /> : null}
</button>
);
})}
{rows.length === 0 && !busy ? <div className="picker-empty">{"No results"}</div> : null}
</div>
</div>
);
}
// BotPicker is the same widget over /api/bots. Verifier status is granted to a bot
// account, and an operator knows the handle rather than the id, so the grant form
// resolves it here instead of asking for a raw number.

View file

@ -6,6 +6,7 @@ import {
Database,
LayoutDashboard,
LogOut,
Megaphone,
MessageSquareText,
ShieldAlert,
ShieldCheck,
@ -92,6 +93,7 @@ export function Shell({
<NavLink icon={<ShieldCheck size={16} />} href="/channels" route={route} navigate={navigate}>{"Supergroups / Channels"}</NavLink>
<NavLink icon={<Bot size={16} />} href="/bots" route={route} navigate={navigate}>{"Bots"}</NavLink>
<NavLink icon={<ShieldAlert size={16} />} href="/moderation" route={route} navigate={navigate}>{"Reports / Moderation"}</NavLink>
<NavLink icon={<Megaphone size={16} />} href="/broadcasts" route={route} navigate={navigate}>{"Broadcasts"}</NavLink>
{canReviewVerification && (
<NavLink icon={<BadgeCheck size={16} />} href="/verification" route={route} navigate={navigate}>{"Verification"}</NavLink>
)}

View file

@ -0,0 +1,152 @@
import { ChevronLeft, ChevronRight, Loader2, RefreshCw, Send } from "lucide-react";
import { useEffect, useState } from "react";
import { api, errorMessage } from "../api";
import { CreateBroadcastModal } from "../components/CreateBroadcastModal";
import { Alert, Badge, EmptyRow, Metric, PageFrame } from "../components/ui";
import { formatDate } from "../lib/format";
import type { BroadcastListResponse } from "../types";
type Cursor = { beforeID: number };
const zeroCursor: Cursor = { beforeID: 0 };
export function BroadcastsPage() {
const [data, setData] = useState<BroadcastListResponse | null>(null);
const [history, setHistory] = useState<Cursor[]>([]);
const [cursor, setCursor] = useState<Cursor>(zeroCursor);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [createModalOpen, setCreateModalOpen] = useState(false);
async function fetchPage(at: Cursor) {
setBusy(true);
setError("");
const params = new URLSearchParams({ limit: "50" });
if (at.beforeID) {
params.set("before_id", String(at.beforeID));
}
try {
const result = await api.broadcasts(params);
setData(result);
return result;
} catch (err) {
setError(errorMessage(err));
return null;
} finally {
setBusy(false);
}
}
async function loadFresh() {
setHistory([]);
setCursor(zeroCursor);
await fetchPage(zeroCursor);
}
async function loadNext() {
if (!data?.has_more) return;
const at = { beforeID: data.next_before_id };
const result = await fetchPage(at);
if (result) {
setHistory((prev) => [...prev, cursor]);
setCursor(at);
}
}
async function loadPrev() {
if (history.length === 0) return;
const at = history[history.length - 1];
const result = await fetchPage(at);
if (result) {
setHistory((prev) => prev.slice(0, -1));
setCursor(at);
}
}
useEffect(() => {
void loadFresh();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const rows = data?.rows ?? [];
const inFlight = rows.filter((row) => row.SentCount + row.FailedCount < row.TotalCount).length;
const canGoPrev = history.length > 0 && !busy;
const canGoNext = Boolean(data?.has_more) && !busy;
return (
<PageFrame
title={"Broadcasts"}
eyebrow={"Announcements sent from the official system account"}
actions={
<>
<button className="btn primary icon-text" type="button" onClick={() => setCreateModalOpen(true)}>
<Send size={15} /> {"Send broadcast"}
</button>
<button className="btn" type="button" onClick={() => void loadFresh()} disabled={busy}>
<RefreshCw size={15} /> {"Refresh"}
</button>
</>
}
>
{error && <Alert>{error}</Alert>}
<div className="metric-row">
<Metric label={"Campaigns on page"} value={String(rows.length)} />
<Metric label={"Still delivering"} value={String(inFlight)} tone={inFlight > 0 ? "warn" : "neutral"} />
</div>
<div className="table-wrap">
<table className="data-table">
<thead>
<tr>
<th>{"ID"}</th>
<th>{"Message"}</th>
<th>{"Target"}</th>
<th>{"Sent"}</th>
<th>{"Failed"}</th>
<th>{"Total"}</th>
<th>{"Created by"}</th>
<th>{"Created"}</th>
</tr>
</thead>
<tbody>
{rows.map((row) => {
const delivered = row.SentCount + row.FailedCount;
const done = row.TotalCount > 0 && delivered >= row.TotalCount;
return (
<tr key={row.ID}>
<td className="mono">{row.ID}</td>
<td className="truncate">{row.Message}</td>
<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.CreatedBy || "-"}</td>
<td>
{formatDate(row.CreatedAt)}
{!done && <Badge tone="warn">{"Sending"}</Badge>}
</td>
</tr>
);
})}
{rows.length === 0 && <EmptyRow colSpan={8} />}
</tbody>
</table>
</div>
<div className="toolbar">
<button className="btn icon-text" type="button" onClick={() => void loadPrev()} disabled={!canGoPrev}>
<ChevronLeft size={15} /> {"Previous page"}
</button>
<button className="btn icon-text" type="button" onClick={() => void loadNext()} disabled={!canGoNext}>
{busy ? <Loader2 size={15} className="spin" /> : <ChevronRight size={15} />} {"Next page"}
</button>
</div>
{createModalOpen && (
<CreateBroadcastModal
onClose={() => setCreateModalOpen(false)}
onCreated={() => void loadFresh()}
/>
)}
</PageFrame>
);
}

View file

@ -10,6 +10,7 @@ import { ChannelDetailPage } from "./ChannelDetailPage";
import { ChannelsPage } from "./ChannelsPage";
import { BotDetailPage } from "./BotDetailPage";
import { BotsPage } from "./BotsPage";
import { BroadcastsPage } from "./BroadcastsPage";
import { Dashboard } from "./Dashboard";
import { GroupMessageDetailPage } from "./GroupMessageDetailPage";
import { GroupMessagesPage } from "./GroupMessagesPage";
@ -121,6 +122,9 @@ export function Routes({ route, navigate }: { route: RouteState; navigate: Navig
if (route.path === "/moderation") {
return <ModerationCasesPage navigate={navigate} />;
}
if (route.path === "/broadcasts") {
return <BroadcastsPage />;
}
if (route.path === "/emoji") {
return <StickerSetsPage kind="emoji" />;
}

View file

@ -27,6 +27,7 @@ export function routeTitle(pathname: string): string {
if (pathname.startsWith("/channels")) return "Supergroups and Channels";
if (pathname.startsWith("/bots")) return "Bots";
if (pathname.startsWith("/moderation")) return "Reports and Moderation";
if (pathname.startsWith("/broadcasts")) return "Broadcasts";
if (pathname.startsWith("/emoji")) return "Emoji";
if (pathname.startsWith("/messages")) return "Message Audit";
if (pathname.startsWith("/give-gifts")) return "Give Gifts";

View file

@ -317,6 +317,40 @@
text-align: center;
}
.picker-chip-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.picker-chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 5px 8px;
color: var(--brand-tint-text);
background: var(--brand-tint);
border: 1px solid var(--brand-tint-border);
border-radius: 999px;
font-size: 11px;
font-weight: 700;
}
.picker-chip button {
display: inline-flex;
align-items: center;
padding: 0;
color: inherit;
background: transparent;
border: 0;
cursor: pointer;
opacity: 0.75;
}
.picker-chip button:hover {
opacity: 1;
}
.emoji-picker-row {
grid-template-columns: 36px minmax(140px, 1fr) minmax(100px, 1fr);
}

View file

@ -872,6 +872,24 @@ export type BotListResponse = {
listing: boolean;
};
export type BroadcastRow = {
ID: number;
Message: string;
TargetMode: string;
TotalCount: number;
SentCount: number;
FailedCount: number;
CreatedBy: string;
CreatedAt: string;
};
export type BroadcastListResponse = {
limit: number;
rows: BroadcastRow[];
has_more: boolean;
next_before_id: number;
};
export type EmojiRow = {
DocumentID: string;
Alt: string;

View file

@ -30,6 +30,7 @@ import (
authdiagnosticsapp "telesrv/internal/app/authdiagnostics"
botsapp "telesrv/internal/app/bots"
botverificationapp "telesrv/internal/app/botverification"
broadcastapp "telesrv/internal/app/broadcast"
channelapp "telesrv/internal/app/channels"
chatlistsapp "telesrv/internal/app/chatlists"
clienttelemetryapp "telesrv/internal/app/clienttelemetry"
@ -1396,6 +1397,10 @@ func run(logger *zap.Logger) error {
}, logger.Named("store").Named("read-model-listener"))
go readModelListener.Run(ctx)
activeSessions.SetLifecycleObserver(router)
broadcastStore := postgres.NewBroadcastStore(pool)
broadcastService := broadcastapp.NewService(broadcastStore,
broadcastapp.WithMessageSender(messageStore),
broadcastapp.WithLogger(logger.Named("broadcast")))
adminService.Configure(adminapp.Dependencies{
Auth: authService,
Revoker: router,
@ -1420,6 +1425,7 @@ func run(logger *zap.Logger) error {
Verification: verificationService,
BotVerification: botVerificationService,
Account: accountService,
Broadcast: broadcastService,
})
// The RPC edge owns the tg.* projection cache and the standard non-PTS
// updateUser/updateChannel refresh, so committed registry mutations are
@ -1479,6 +1485,12 @@ func run(logger *zap.Logger) error {
// a message send.
go verificationapp.NewNotificationWorker(verificationService, logger.Named("verification").Named("notify"),
cfg.VerificationNotifyInterval, cfg.VerificationNotifyBatch).Run(ctx)
// System broadcasts (admin panel "Broadcasts" -- a message from 777000 to
// 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)
moderationActionOptions := []moderationapp.ActionExecutorOption{}
if cfg.PublicLinkWebAddr != "" {
moderationActionOptions = append(

View file

@ -0,0 +1,2 @@
DROP TABLE IF EXISTS public.broadcast_recipients;
DROP TABLE IF EXISTS public.broadcasts;

View file

@ -0,0 +1,32 @@
-- Admin-triggered broadcast messages sent from the official system account
-- (777000) to all users or a hand-picked list. Delivery is a durable outbox
-- (broadcast_recipients) drained by a periodic worker, mirroring the
-- verification_notification_outbox pattern: the admin action only has to
-- snapshot the recipient list and return, not send potentially thousands of
-- messages inline within one HTTP request.
CREATE TABLE public.broadcasts (
id bigserial PRIMARY KEY,
message text NOT NULL,
target_mode character varying(16) NOT NULL,
total_count integer NOT NULL DEFAULT 0,
created_by character varying(64) NOT NULL DEFAULT '',
created_at timestamp with time zone NOT NULL DEFAULT now()
);
CREATE TABLE public.broadcast_recipients (
id bigserial PRIMARY KEY,
broadcast_id bigint NOT NULL REFERENCES public.broadcasts(id) ON DELETE CASCADE,
user_id bigint NOT NULL,
status character varying(16) NOT NULL DEFAULT 'pending',
attempts integer NOT NULL DEFAULT 0,
last_error text NOT NULL DEFAULT '',
sent_at timestamp with time zone,
UNIQUE (broadcast_id, user_id)
);
-- The worker's whole read pattern is "give me pending rows, oldest first";
-- a partial index keeps that cheap forever regardless of how many rows have
-- already settled into sent/failed.
CREATE INDEX idx_broadcast_recipients_pending ON public.broadcast_recipients (id) WHERE status = 'pending';
CREATE INDEX idx_broadcast_recipients_broadcast ON public.broadcast_recipients (broadcast_id);

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,6 +444,8 @@ 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
// Broadcast is the system-broadcast (777000) create/list/get surface.
Broadcast BroadcastService
Now func() time.Time
}
@ -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
}