added ability to broadcast
This commit is contained in:
parent
7d41cbeb1e
commit
2491088e81
31 changed files with 1607 additions and 18 deletions
|
|
@ -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
|
||||
|
|
@ -870,9 +895,9 @@ ORDER BY g.last_active_at DESC, g.device_model, g.system_version, g.platform, g.
|
|||
for rows.Next() {
|
||||
var (
|
||||
deviceModel, systemVersion, platform, ip string
|
||||
accountCount int
|
||||
lastActiveAt time.Time
|
||||
acc SharedDeviceAccount
|
||||
accountCount int
|
||||
lastActiveAt time.Time
|
||||
acc SharedDeviceAccount
|
||||
)
|
||||
if err := rows.Scan(&deviceModel, &systemVersion, &platform, &ip, &accountCount, &lastActiveAt,
|
||||
&acc.UserID, &acc.ActiveAt, &acc.Phone, &acc.Username, &acc.FirstName, &acc.LastName); err != nil {
|
||||
|
|
@ -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, `
|
||||
|
|
@ -2727,7 +2810,7 @@ SELECT count(DISTINCT owner_user_id)::bigint FROM (`+perOwnerMediaSizeSQL+`) x W
|
|||
// AccountStorageRow is one account's row in the per-account storage
|
||||
// breakdown table.
|
||||
type AccountStorageRow struct {
|
||||
UserID int64 `json:"UserID,string"`
|
||||
UserID int64 `json:"UserID,string"`
|
||||
Username string
|
||||
FirstName string
|
||||
Bytes int64 `json:"Bytes,string"`
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue