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
|
# Applications one applicant may keep open at once; 0 disables the cap, maximum
|
||||||
# is 50.
|
# is 50.
|
||||||
TELESRV_VERIFICATION_MAX_ACTIVE_PER_USER=3
|
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
|
# Tuning for third-party bot verification; the on/off switch
|
||||||
# (TELESRV_BOT_VERIFICATION_ENABLED) is in the Moderation/Verification/Rating
|
# (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
|
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
|
// CountOnlineAccounts counts accounts the live server currently considers
|
||||||
// online. Presence itself lives only in the live server's in-process
|
// online. Presence itself lives only in the live server's in-process
|
||||||
// presenceTracker (internal/rpc/presence.go), unreachable from this
|
// 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() {
|
for rows.Next() {
|
||||||
var (
|
var (
|
||||||
deviceModel, systemVersion, platform, ip string
|
deviceModel, systemVersion, platform, ip string
|
||||||
accountCount int
|
accountCount int
|
||||||
lastActiveAt time.Time
|
lastActiveAt time.Time
|
||||||
acc SharedDeviceAccount
|
acc SharedDeviceAccount
|
||||||
)
|
)
|
||||||
if err := rows.Scan(&deviceModel, &systemVersion, &platform, &ip, &accountCount, &lastActiveAt,
|
if err := rows.Scan(&deviceModel, &systemVersion, &platform, &ip, &accountCount, &lastActiveAt,
|
||||||
&acc.UserID, &acc.ActiveAt, &acc.Phone, &acc.Username, &acc.FirstName, &acc.LastName); err != nil {
|
&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
|
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) {
|
func (s *readStore) AccountDetail(ctx context.Context, userID int64) (AccountDetail, error) {
|
||||||
var out AccountDetail
|
var out AccountDetail
|
||||||
err := s.pool.QueryRow(ctx, `
|
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
|
// AccountStorageRow is one account's row in the per-account storage
|
||||||
// breakdown table.
|
// breakdown table.
|
||||||
type AccountStorageRow struct {
|
type AccountStorageRow struct {
|
||||||
UserID int64 `json:"UserID,string"`
|
UserID int64 `json:"UserID,string"`
|
||||||
Username string
|
Username string
|
||||||
FirstName string
|
FirstName string
|
||||||
Bytes int64 `json:"Bytes,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", s.requireAuthAPI(http.HandlerFunc(s.handleAccountsAPI)))
|
||||||
mux.Handle("GET /api/accounts/stats", s.requireAuthAPI(http.HandlerFunc(s.handleAccountsStatsAPI)))
|
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/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}", s.requireAuthAPI(http.HandlerFunc(s.handleAccountDetailAPI)))
|
||||||
mux.Handle("GET /api/accounts/{id}/avatar", s.requireAuthAPI(http.HandlerFunc(s.handleAccountAvatarAPI)))
|
mux.Handle("GET /api/accounts/{id}/avatar", s.requireAuthAPI(http.HandlerFunc(s.handleAccountAvatarAPI)))
|
||||||
mux.Handle("GET /api/channels", s.requireAuthAPI(http.HandlerFunc(s.handleChannelsAPI)))
|
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-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/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-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/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/export-bot-token", s.requireAuthAPI(http.HandlerFunc(s.handleExportBotTokenAPI)))
|
||||||
mux.Handle("POST /api/actions/set-channel-verified", s.requireAuthAPI(http.HandlerFunc(s.handleSetChannelVerifiedAPI)))
|
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) {
|
func (s *server) handleAccountsStatsAPI(w http.ResponseWriter, r *http.Request) {
|
||||||
if s.read == nil {
|
if s.read == nil {
|
||||||
writeAPIError(w, http.StatusServiceUnavailable, "read store is not configured")
|
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)
|
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 {
|
type deleteBotAPIRequest struct {
|
||||||
CommandID string `json:"command_id"`
|
CommandID string `json:"command_id"`
|
||||||
Reason string `json:"reason"`
|
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>
|
||||||
|
|
||||||
<script type="module" crossorigin src="/assets/index-DWg-er34.js"></script>
|
<script type="module" crossorigin src="/assets/index-CS-EAiSc.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-Bv1l1T8K.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-sqNghGhC.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import type {
|
||||||
BotListResponse,
|
BotListResponse,
|
||||||
BotVerificationCountsResponse,
|
BotVerificationCountsResponse,
|
||||||
BotVerifierListResponse,
|
BotVerifierListResponse,
|
||||||
|
BroadcastListResponse,
|
||||||
ChannelDetail,
|
ChannelDetail,
|
||||||
CustomVerificationListResponse,
|
CustomVerificationListResponse,
|
||||||
CustomVerificationRequestDetail,
|
CustomVerificationRequestDetail,
|
||||||
|
|
@ -160,6 +161,7 @@ export const api = {
|
||||||
channels: (params: URLSearchParams) => request<ChannelListResponse>(`/api/channels?${params.toString()}`),
|
channels: (params: URLSearchParams) => request<ChannelListResponse>(`/api/channels?${params.toString()}`),
|
||||||
channel: (id: number) => request<ChannelDetail>(`/api/channels/${id}`),
|
channel: (id: number) => request<ChannelDetail>(`/api/channels/${id}`),
|
||||||
bots: (params: URLSearchParams) => request<BotListResponse>(`/api/bots?${params.toString()}`),
|
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}`),
|
bot: (id: number) => request<BotDetail>(`/api/bots/${id}`),
|
||||||
collectibleUsernames: (params: URLSearchParams) =>
|
collectibleUsernames: (params: URLSearchParams) =>
|
||||||
request<CollectibleUsernameListResponse>(`/api/collectible-usernames?${params.toString()}`),
|
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
|
// 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
|
// 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.
|
// resolves it here instead of asking for a raw number.
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import {
|
||||||
Database,
|
Database,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
LogOut,
|
LogOut,
|
||||||
|
Megaphone,
|
||||||
MessageSquareText,
|
MessageSquareText,
|
||||||
ShieldAlert,
|
ShieldAlert,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
|
|
@ -92,6 +93,7 @@ export function Shell({
|
||||||
<NavLink icon={<ShieldCheck size={16} />} href="/channels" route={route} navigate={navigate}>{"Supergroups / Channels"}</NavLink>
|
<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={<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={<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 && (
|
{canReviewVerification && (
|
||||||
<NavLink icon={<BadgeCheck size={16} />} href="/verification" route={route} navigate={navigate}>{"Verification"}</NavLink>
|
<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 { ChannelsPage } from "./ChannelsPage";
|
||||||
import { BotDetailPage } from "./BotDetailPage";
|
import { BotDetailPage } from "./BotDetailPage";
|
||||||
import { BotsPage } from "./BotsPage";
|
import { BotsPage } from "./BotsPage";
|
||||||
|
import { BroadcastsPage } from "./BroadcastsPage";
|
||||||
import { Dashboard } from "./Dashboard";
|
import { Dashboard } from "./Dashboard";
|
||||||
import { GroupMessageDetailPage } from "./GroupMessageDetailPage";
|
import { GroupMessageDetailPage } from "./GroupMessageDetailPage";
|
||||||
import { GroupMessagesPage } from "./GroupMessagesPage";
|
import { GroupMessagesPage } from "./GroupMessagesPage";
|
||||||
|
|
@ -121,6 +122,9 @@ export function Routes({ route, navigate }: { route: RouteState; navigate: Navig
|
||||||
if (route.path === "/moderation") {
|
if (route.path === "/moderation") {
|
||||||
return <ModerationCasesPage navigate={navigate} />;
|
return <ModerationCasesPage navigate={navigate} />;
|
||||||
}
|
}
|
||||||
|
if (route.path === "/broadcasts") {
|
||||||
|
return <BroadcastsPage />;
|
||||||
|
}
|
||||||
if (route.path === "/emoji") {
|
if (route.path === "/emoji") {
|
||||||
return <StickerSetsPage kind="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("/channels")) return "Supergroups and Channels";
|
||||||
if (pathname.startsWith("/bots")) return "Bots";
|
if (pathname.startsWith("/bots")) return "Bots";
|
||||||
if (pathname.startsWith("/moderation")) return "Reports and Moderation";
|
if (pathname.startsWith("/moderation")) return "Reports and Moderation";
|
||||||
|
if (pathname.startsWith("/broadcasts")) return "Broadcasts";
|
||||||
if (pathname.startsWith("/emoji")) return "Emoji";
|
if (pathname.startsWith("/emoji")) return "Emoji";
|
||||||
if (pathname.startsWith("/messages")) return "Message Audit";
|
if (pathname.startsWith("/messages")) return "Message Audit";
|
||||||
if (pathname.startsWith("/give-gifts")) return "Give Gifts";
|
if (pathname.startsWith("/give-gifts")) return "Give Gifts";
|
||||||
|
|
|
||||||
|
|
@ -317,6 +317,40 @@
|
||||||
text-align: center;
|
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 {
|
.emoji-picker-row {
|
||||||
grid-template-columns: 36px minmax(140px, 1fr) minmax(100px, 1fr);
|
grid-template-columns: 36px minmax(140px, 1fr) minmax(100px, 1fr);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -872,6 +872,24 @@ export type BotListResponse = {
|
||||||
listing: boolean;
|
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 = {
|
export type EmojiRow = {
|
||||||
DocumentID: string;
|
DocumentID: string;
|
||||||
Alt: string;
|
Alt: string;
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ import (
|
||||||
authdiagnosticsapp "telesrv/internal/app/authdiagnostics"
|
authdiagnosticsapp "telesrv/internal/app/authdiagnostics"
|
||||||
botsapp "telesrv/internal/app/bots"
|
botsapp "telesrv/internal/app/bots"
|
||||||
botverificationapp "telesrv/internal/app/botverification"
|
botverificationapp "telesrv/internal/app/botverification"
|
||||||
|
broadcastapp "telesrv/internal/app/broadcast"
|
||||||
channelapp "telesrv/internal/app/channels"
|
channelapp "telesrv/internal/app/channels"
|
||||||
chatlistsapp "telesrv/internal/app/chatlists"
|
chatlistsapp "telesrv/internal/app/chatlists"
|
||||||
clienttelemetryapp "telesrv/internal/app/clienttelemetry"
|
clienttelemetryapp "telesrv/internal/app/clienttelemetry"
|
||||||
|
|
@ -1396,6 +1397,10 @@ func run(logger *zap.Logger) error {
|
||||||
}, logger.Named("store").Named("read-model-listener"))
|
}, logger.Named("store").Named("read-model-listener"))
|
||||||
go readModelListener.Run(ctx)
|
go readModelListener.Run(ctx)
|
||||||
activeSessions.SetLifecycleObserver(router)
|
activeSessions.SetLifecycleObserver(router)
|
||||||
|
broadcastStore := postgres.NewBroadcastStore(pool)
|
||||||
|
broadcastService := broadcastapp.NewService(broadcastStore,
|
||||||
|
broadcastapp.WithMessageSender(messageStore),
|
||||||
|
broadcastapp.WithLogger(logger.Named("broadcast")))
|
||||||
adminService.Configure(adminapp.Dependencies{
|
adminService.Configure(adminapp.Dependencies{
|
||||||
Auth: authService,
|
Auth: authService,
|
||||||
Revoker: router,
|
Revoker: router,
|
||||||
|
|
@ -1420,6 +1425,7 @@ func run(logger *zap.Logger) error {
|
||||||
Verification: verificationService,
|
Verification: verificationService,
|
||||||
BotVerification: botVerificationService,
|
BotVerification: botVerificationService,
|
||||||
Account: accountService,
|
Account: accountService,
|
||||||
|
Broadcast: broadcastService,
|
||||||
})
|
})
|
||||||
// The RPC edge owns the tg.* projection cache and the standard non-PTS
|
// The RPC edge owns the tg.* projection cache and the standard non-PTS
|
||||||
// updateUser/updateChannel refresh, so committed registry mutations are
|
// updateUser/updateChannel refresh, so committed registry mutations are
|
||||||
|
|
@ -1479,6 +1485,12 @@ func run(logger *zap.Logger) error {
|
||||||
// a message send.
|
// a message send.
|
||||||
go verificationapp.NewNotificationWorker(verificationService, logger.Named("verification").Named("notify"),
|
go verificationapp.NewNotificationWorker(verificationService, logger.Named("verification").Named("notify"),
|
||||||
cfg.VerificationNotifyInterval, cfg.VerificationNotifyBatch).Run(ctx)
|
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{}
|
moderationActionOptions := []moderationapp.ActionExecutorOption{}
|
||||||
if cfg.PublicLinkWebAddr != "" {
|
if cfg.PublicLinkWebAddr != "" {
|
||||||
moderationActionOptions = append(
|
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"
|
ActionSetStarGiftSortOrder = "gifts.set_sort_order"
|
||||||
ActionGiveGift = "gifts.give"
|
ActionGiveGift = "gifts.give"
|
||||||
ActionCreateBot = "bot.create"
|
ActionCreateBot = "bot.create"
|
||||||
|
ActionCreateBroadcast = "broadcast.create"
|
||||||
ActionDeleteBot = "bot.delete"
|
ActionDeleteBot = "bot.delete"
|
||||||
ActionExportBotToken = "bot.export_token"
|
ActionExportBotToken = "bot.export_token"
|
||||||
ActionSetStickerSetArchived = "stickers.set_archived"
|
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)
|
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 {
|
type StarsNotifier interface {
|
||||||
NotifyStarsBalanceChanged(ctx context.Context, balance domain.StarsBalance) error
|
NotifyStarsBalanceChanged(ctx context.Context, balance domain.StarsBalance) error
|
||||||
}
|
}
|
||||||
|
|
@ -428,7 +444,9 @@ type Dependencies struct {
|
||||||
// Account carries the login-email factor -- a separate app service from
|
// Account carries the login-email factor -- a separate app service from
|
||||||
// Users, since login email lives in account_passwords, not users.
|
// Users, since login email lives in account_passwords, not users.
|
||||||
Account AccountService
|
Account AccountService
|
||||||
Now func() time.Time
|
// Broadcast is the system-broadcast (777000) create/list/get surface.
|
||||||
|
Broadcast BroadcastService
|
||||||
|
Now func() time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
|
|
@ -458,6 +476,7 @@ type Service struct {
|
||||||
verification VerificationService
|
verification VerificationService
|
||||||
botVerification BotVerificationService
|
botVerification BotVerificationService
|
||||||
account AccountService
|
account AccountService
|
||||||
|
broadcast BroadcastService
|
||||||
now func() time.Time
|
now func() time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -545,6 +564,9 @@ func (s *Service) Configure(deps Dependencies) *Service {
|
||||||
if deps.Account != nil {
|
if deps.Account != nil {
|
||||||
s.account = deps.Account
|
s.account = deps.Account
|
||||||
}
|
}
|
||||||
|
if deps.Broadcast != nil {
|
||||||
|
s.broadcast = deps.Broadcast
|
||||||
|
}
|
||||||
if deps.Now != nil {
|
if deps.Now != nil {
|
||||||
s.now = deps.Now
|
s.now = deps.Now
|
||||||
}
|
}
|
||||||
|
|
@ -1032,6 +1054,18 @@ type DeleteBotRequest struct {
|
||||||
BotUserID int64 `json:"bot_user_id"`
|
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 {
|
type ExportBotTokenRequest struct {
|
||||||
CommandMeta
|
CommandMeta
|
||||||
BotUserID int64 `json:"bot_user_id"`
|
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
|
// ExportBotToken returns a non-system bot's current token (unrotated) via the
|
||||||
// audited runCommand wrapper. Like CreateBot's token, it travels only in
|
// audited runCommand wrapper. Like CreateBot's token, it travels only in
|
||||||
// transientDetails -- excluded from the stored/replayed command JSON so it
|
// 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)
|
SetChannelVerified(ctx context.Context, req admin.SetChannelVerifiedRequest) (admin.CommandResult, error)
|
||||||
SetChannelFlags(ctx context.Context, req admin.SetChannelFlagsRequest) (admin.CommandResult, error)
|
SetChannelFlags(ctx context.Context, req admin.SetChannelFlagsRequest) (admin.CommandResult, error)
|
||||||
CreateBot(ctx context.Context, req admin.CreateBotRequest) (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)
|
DeleteBot(ctx context.Context, req admin.DeleteBotRequest) (admin.CommandResult, error)
|
||||||
ExportBotToken(ctx context.Context, req admin.ExportBotTokenRequest) (admin.CommandResult, error)
|
ExportBotToken(ctx context.Context, req admin.ExportBotTokenRequest) (admin.CommandResult, error)
|
||||||
SetSupport(ctx context.Context, req admin.SetSupportRequest) (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-color", s.authenticated(s.handleSetChannelColor))
|
||||||
mux.HandleFunc("POST /v1/channels/set-emoji-status", s.authenticated(s.handleSetChannelEmojiStatus))
|
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/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/delete", s.authenticated(s.handleDeleteBot))
|
||||||
mux.HandleFunc("POST /v1/bots/export-token", s.authenticated(s.handleExportBotToken))
|
mux.HandleFunc("POST /v1/bots/export-token", s.authenticated(s.handleExportBotToken))
|
||||||
mux.HandleFunc("POST /v1/messages/delete", s.authenticated(s.handleDeleteMessages))
|
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)
|
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) {
|
func (s *Server) handleDeleteBot(w http.ResponseWriter, r *http.Request) {
|
||||||
var req admin.DeleteBotRequest
|
var req admin.DeleteBotRequest
|
||||||
if !decodeJSON(w, r, &req) {
|
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
|
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) {
|
func (fakeService) DeleteBot(_ context.Context, req admin.DeleteBotRequest) (admin.CommandResult, error) {
|
||||||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
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.
|
// verifier bots. 0 for either disables the budget.
|
||||||
BotVerificationRequestRateLimit int
|
BotVerificationRequestRateLimit int
|
||||||
BotVerificationRequestRateWindow time.Duration
|
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
|
// HideThirdPartyVerification hides third-party bot verification instead of
|
||||||
// removing it: the admin panel drops its "Third-party marks" nav entry and
|
// removing it: the admin panel drops its "Third-party marks" nav entry and
|
||||||
// refuses every botverification.* route with 404 (regardless of session
|
// 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.
|
// verifier bots, and filing with a second company is not a retry of the first.
|
||||||
BotVerificationRequestRateLimit: envIntOr("TELESRV_BOT_VERIFICATION_REQUEST_RATE_LIMIT", 5),
|
BotVerificationRequestRateLimit: envIntOr("TELESRV_BOT_VERIFICATION_REQUEST_RATE_LIMIT", 5),
|
||||||
BotVerificationRequestRateWindow: envDurationOr("TELESRV_BOT_VERIFICATION_REQUEST_RATE_WINDOW", 24*time.Hour),
|
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),
|
HideThirdPartyVerification: envBoolOr("TELESRV_HIDE_THIRD_PARTY_VERIFICATION", true),
|
||||||
|
|
||||||
GroupCallCheckTTL: envDurationOr("TELESRV_GROUPCALL_CHECK_TTL", 45*time.Second),
|
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