added ability to broadcast

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

View file

@ -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"`

View file

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -30,6 +30,7 @@ import (
authdiagnosticsapp "telesrv/internal/app/authdiagnostics"
botsapp "telesrv/internal/app/bots"
botverificationapp "telesrv/internal/app/botverification"
broadcastapp "telesrv/internal/app/broadcast"
channelapp "telesrv/internal/app/channels"
chatlistsapp "telesrv/internal/app/chatlists"
clienttelemetryapp "telesrv/internal/app/clienttelemetry"
@ -1396,6 +1397,10 @@ func run(logger *zap.Logger) error {
}, logger.Named("store").Named("read-model-listener"))
go readModelListener.Run(ctx)
activeSessions.SetLifecycleObserver(router)
broadcastStore := postgres.NewBroadcastStore(pool)
broadcastService := broadcastapp.NewService(broadcastStore,
broadcastapp.WithMessageSender(messageStore),
broadcastapp.WithLogger(logger.Named("broadcast")))
adminService.Configure(adminapp.Dependencies{
Auth: authService,
Revoker: router,
@ -1420,6 +1425,7 @@ func run(logger *zap.Logger) error {
Verification: verificationService,
BotVerification: botVerificationService,
Account: accountService,
Broadcast: broadcastService,
})
// The RPC edge owns the tg.* projection cache and the standard non-PTS
// updateUser/updateChannel refresh, so committed registry mutations are
@ -1479,6 +1485,12 @@ func run(logger *zap.Logger) error {
// a message send.
go verificationapp.NewNotificationWorker(verificationService, logger.Named("verification").Named("notify"),
cfg.VerificationNotifyInterval, cfg.VerificationNotifyBatch).Run(ctx)
// System broadcasts (admin panel "Broadcasts" -- a message from 777000 to
// all/selected users) are delivered from the same kind of durable outbox as
// applicant notifications above: an admin creating one for every user must
// not wait on however long sending to all of them takes.
go broadcastapp.NewWorker(broadcastService, logger.Named("broadcast").Named("delivery"),
cfg.BroadcastWorkerInterval, cfg.BroadcastWorkerBatch).Run(ctx)
moderationActionOptions := []moderationapp.ActionExecutorOption{}
if cfg.PublicLinkWebAddr != "" {
moderationActionOptions = append(