added ability to broadcast
This commit is contained in:
parent
7d41cbeb1e
commit
2491088e81
31 changed files with 1607 additions and 18 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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, `
|
||||
|
|
|
|||
|
|
@ -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
10
cmd/telesrv-admin/web/dist/assets/index-CS-EAiSc.js
vendored
Normal file
10
cmd/telesrv-admin/web/dist/assets/index-CS-EAiSc.js
vendored
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
cmd/telesrv-admin/web/dist/assets/index-sqNghGhC.css
vendored
Normal file
1
cmd/telesrv-admin/web/dist/assets/index-sqNghGhC.css
vendored
Normal file
File diff suppressed because one or more lines are too long
4
cmd/telesrv-admin/web/dist/index.html
vendored
4
cmd/telesrv-admin/web/dist/index.html
vendored
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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()}`),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
152
cmd/telesrv-admin/web/src/pages/BroadcastsPage.tsx
Normal file
152
cmd/telesrv-admin/web/src/pages/BroadcastsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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" />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,2 @@
|
|||
DROP TABLE IF EXISTS public.broadcast_recipients;
|
||||
DROP TABLE IF EXISTS public.broadcasts;
|
||||
32
deploy/migrations/20260714003131_system_broadcasts.up.sql
Normal file
32
deploy/migrations/20260714003131_system_broadcasts.up.sql
Normal 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);
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
159
internal/app/broadcast/service.go
Normal file
159
internal/app/broadcast/service.go
Normal 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
|
||||
}
|
||||
131
internal/app/broadcast/service_test.go
Normal file
131
internal/app/broadcast/service_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
84
internal/app/broadcast/worker.go
Normal file
84
internal/app/broadcast/worker.go
Normal 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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
70
internal/domain/broadcast.go
Normal file
70
internal/domain/broadcast.go
Normal 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")
|
||||
)
|
||||
44
internal/store/broadcast.go
Normal file
44
internal/store/broadcast.go
Normal 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
|
||||
}
|
||||
188
internal/store/memory/broadcast.go
Normal file
188
internal/store/memory/broadcast.go
Normal 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] })
|
||||
}
|
||||
193
internal/store/postgres/broadcast.go
Normal file
193
internal/store/postgres/broadcast.go
Normal 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue