Compare commits
40 commits
2d42158ec5
...
3328ccbeab
| Author | SHA1 | Date | |
|---|---|---|---|
| 3328ccbeab | |||
| d1108c61f1 | |||
| 66091ede72 | |||
| 25b1d6399c | |||
| fa95dab29a | |||
| 681802e893 | |||
| 2f7ad81ae4 | |||
| 216dd151e4 | |||
| be8afdd7d9 | |||
| a83aa45fb8 | |||
| d2ffaa92bf | |||
| 11dd7660c0 | |||
| d641725622 | |||
| d022521d67 | |||
| 78267ee0d3 | |||
| fade2bca67 | |||
| 1b7efc50e9 | |||
| f8f2c4bad4 | |||
| 083e54e145 | |||
| 57a0c5ef23 | |||
| e927d4d18a | |||
| 86f9b61336 | |||
| 236e9ed25a | |||
| c97255cb48 | |||
| e19ad9960e | |||
| f6d7ef4652 | |||
| f7a583813b | |||
| f0479ecf9a | |||
| ef25da6462 | |||
| a9fbb1f80d | |||
| b52d3f3f64 | |||
| 69d64f3d80 | |||
| 63c565e490 | |||
| 2419f47236 | |||
| d55a97f0a5 | |||
| df15c3ffb3 | |||
| c376262cff | |||
| 5f63240f2d | |||
| 41f65bf0fc | |||
| 443ca300b9 |
61 changed files with 2447 additions and 111 deletions
|
|
@ -1,7 +1,22 @@
|
|||
FROM docker.io/library/golang:1.25 AS build
|
||||
# Build metadata for telesrv's startup log (git_commit/git_branch/... in
|
||||
# cmd/telesrv/buildinfo.go). .containerignore excludes .git, so go build's
|
||||
# automatic VCS stamping sees no repo; pass these in explicitly, e.g.:
|
||||
# podman build \
|
||||
# --build-arg GIT_COMMIT="$(git rev-parse HEAD)" \
|
||||
# --build-arg GIT_BRANCH="$(git rev-parse --abbrev-ref HEAD)" \
|
||||
# --build-arg GIT_TREE_STATE="$(git diff --quiet && echo clean || echo dirty)" \
|
||||
# --build-arg BUILD_TIME="$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
||||
# -t owpengram-server -f Containerfile .
|
||||
ARG GIT_COMMIT=unknown
|
||||
ARG GIT_BRANCH=unknown
|
||||
ARG GIT_TREE_STATE=unknown
|
||||
ARG BUILD_TIME=unknown
|
||||
WORKDIR /src
|
||||
COPY . .
|
||||
RUN CGO_ENABLED=0 go build -trimpath -o /out/gramsrv ./cmd/telesrv
|
||||
RUN CGO_ENABLED=0 go build -trimpath \
|
||||
-ldflags "-X main.gitCommit=${GIT_COMMIT} -X main.gitBranch=${GIT_BRANCH} -X main.gitTreeState=${GIT_TREE_STATE} -X main.buildTime=${BUILD_TIME}" \
|
||||
-o /out/gramsrv ./cmd/telesrv
|
||||
RUN CGO_ENABLED=0 go build -trimpath -o /out/telesrv-admin ./cmd/telesrv-admin
|
||||
|
||||
FROM docker.io/library/alpine:3.20
|
||||
|
|
|
|||
56
build.sh
Executable file
56
build.sh
Executable file
|
|
@ -0,0 +1,56 @@
|
|||
#!/usr/bin/env bash
|
||||
# Build the owpengram-server container image (stamping the current git state into
|
||||
# the binary - .containerignore excludes .git, so go build can't see the repo and
|
||||
# the values are passed in here), then recreate and start the pod containers.
|
||||
#
|
||||
# Usage: ./build.sh [extra podman build args...]
|
||||
# IMAGE=my/tag ./build.sh override the image tag (default: owpengram-server)
|
||||
# POD=name ./build.sh override the pod name (default: owpengram)
|
||||
# NO_DEPLOY=1 ./build.sh build the image only, don't touch containers
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
IMAGE="${IMAGE:-owpengram-server}"
|
||||
POD="${POD:-owpengram}"
|
||||
|
||||
podman build \
|
||||
--build-arg GIT_COMMIT="$(git rev-parse HEAD)" \
|
||||
--build-arg GIT_BRANCH="$(git rev-parse --abbrev-ref HEAD)" \
|
||||
--build-arg GIT_TREE_STATE="$(git diff --quiet && echo clean || echo dirty)" \
|
||||
--build-arg BUILD_TIME="$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
||||
-t "$IMAGE" \
|
||||
-f Containerfile \
|
||||
"$@" \
|
||||
.
|
||||
|
||||
if [ "${NO_DEPLOY:-0}" = "1" ]; then
|
||||
echo "built $IMAGE (NO_DEPLOY=1, containers unchanged)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if ! podman pod exists "$POD"; then
|
||||
echo "pod '$POD' does not exist - creating it"
|
||||
podman pod create --name "$POD" \
|
||||
-p 2398:2398 \
|
||||
-p 127.0.0.1:2600:2600 \
|
||||
-p 2400:2400 \
|
||||
-p 2500:2500 \
|
||||
-p 12399:12399/udp \
|
||||
-p 12400:12400/udp \
|
||||
-p 12500-12999:12500-12999/udp
|
||||
fi
|
||||
if [ ! -f .env ]; then
|
||||
echo "error: .env not found (containers are created with --env-file .env)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
podman create --replace --pod "$POD" --name owpengram-server --restart unless-stopped \
|
||||
--pull never --env-file .env -v owpengram_serverdata:/data \
|
||||
"$IMAGE"
|
||||
|
||||
podman create --replace --pod "$POD" --name owpengram-admin --restart unless-stopped \
|
||||
--pull never --env-file .env --entrypoint /app/telesrv-admin \
|
||||
"$IMAGE"
|
||||
|
||||
podman start owpengram-server owpengram-admin
|
||||
podman ps --pod --filter "pod=$POD" --format 'table {{.Names}} {{.Status}} {{.Image}}'
|
||||
|
|
@ -837,18 +837,21 @@ WITH auth AS (
|
|||
SELECT u.id, u.phone, u.username, u.first_name, u.last_name, u.created_at, u.updated_at,
|
||||
COALESCE(r.frozen, false), COALESCE(r.reason, ''), u.verified, u.scam, u.fake,
|
||||
COALESCE(EXTRACT(EPOCH FROM u.premium_expires_at), 0)::bigint,
|
||||
auth.last_active_at, auth.device_count,
|
||||
COALESCE(auth.last_active_at, '0001-01-01 00:00:00+00'::timestamptz), COALESCE(auth.device_count, 0)::int,
|
||||
COALESCE(NULLIF(u.username, ''), p.username_lower, '') AS display_username,
|
||||
COALESCE(ap.login_email, ''),
|
||||
`+accountCollectibleUsernamesColumn+` AS collectibles
|
||||
FROM users u
|
||||
JOIN auth ON auth.user_id = u.id
|
||||
-- LEFT JOIN, not JOIN: an account with no authorizations (never finished login,
|
||||
-- all sessions revoked, frozen-then-unfrozen) must still appear here, matching
|
||||
-- CountAccounts and SearchAccounts.
|
||||
LEFT JOIN auth ON auth.user_id = u.id
|
||||
LEFT JOIN account_restrictions r ON r.user_id = u.id
|
||||
LEFT JOIN peer_usernames p ON p.peer_type = 'user' AND p.peer_id = u.id AND p.editable
|
||||
LEFT JOIN account_passwords ap ON ap.user_id = u.id
|
||||
WHERE NOT u.is_bot
|
||||
AND ($1::bigint = 0 OR (auth.last_active_at, u.id) < (to_timestamp(($1::double precision) / 1000000.0), $2::bigint))
|
||||
ORDER BY auth.last_active_at DESC, u.id DESC
|
||||
AND ($1::bigint = 0 OR (COALESCE(auth.last_active_at, '0001-01-01 00:00:00+00'::timestamptz), u.id) < (to_timestamp(($1::double precision) / 1000000.0), $2::bigint))
|
||||
ORDER BY COALESCE(auth.last_active_at, '0001-01-01 00:00:00+00'::timestamptz) DESC, u.id DESC
|
||||
LIMIT $3`, beforeActiveUS, beforeID, limit+1)
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("list accounts: %w", err)
|
||||
|
|
|
|||
|
|
@ -47,8 +47,8 @@ VALUES ($1, $2, $3, 'Collector', '', $4, now(), now())`,
|
|||
userID, userID, "+1889"+suffix, editable); err != nil {
|
||||
t.Fatalf("seed user: %v", err)
|
||||
}
|
||||
// The list query joins authorizations, so an account with no device never
|
||||
// appears there at all; an authorization in turn needs its auth key to exist.
|
||||
// Give this account a device so its device_count / last_active columns are
|
||||
// exercised; an authorization needs its auth key to exist first.
|
||||
if _, err := pool.Exec(ctx, `
|
||||
INSERT INTO auth_keys (auth_key_id, body, server_salt) VALUES ($1, '\x00', 0)`, userID); err != nil {
|
||||
t.Fatalf("seed auth key: %v", err)
|
||||
|
|
@ -123,6 +123,44 @@ WHERE peer_type='user' AND peer_id=$1 AND collectible_id IS NOT NULL`, userID);
|
|||
}
|
||||
}
|
||||
|
||||
// An account with no authorizations (never finished login, all sessions revoked,
|
||||
// frozen-then-unfrozen) must still show up in the Accounts tab - it did not,
|
||||
// because ListAccounts inner-joined the authorizations aggregate.
|
||||
func TestReadStoreListAccountsIncludesAccountsWithoutSessions(t *testing.T) {
|
||||
store, pool := verificationReadStore(t)
|
||||
ctx := context.Background()
|
||||
suffix := fmt.Sprintf("%d", time.Now().UnixNano()%1_000_000)
|
||||
userID := 3_700_000_000 + time.Now().UnixNano()%1_000_000
|
||||
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM users WHERE id=$1`, userID)
|
||||
})
|
||||
if _, err := pool.Exec(ctx, `
|
||||
INSERT INTO users (id, access_hash, phone, first_name, last_name, username, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, 'Sessionless', '', '', now(), now())`,
|
||||
userID, userID, "+42777"+suffix); err != nil {
|
||||
t.Fatalf("seed user: %v", err)
|
||||
}
|
||||
// Deliberately no auth_keys / authorizations rows.
|
||||
|
||||
rows, _, err := store.ListAccounts(ctx, 0, 0, 500)
|
||||
if err != nil {
|
||||
t.Fatalf("ListAccounts: %v", err)
|
||||
}
|
||||
found := false
|
||||
for i := range rows {
|
||||
if rows[i].ID == userID {
|
||||
found = true
|
||||
if rows[i].DeviceCount != 0 {
|
||||
t.Fatalf("device count = %d, want 0 for a sessionless account", rows[i].DeviceCount)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("sessionless account %d absent from ListAccounts (%d rows)", userID, len(rows))
|
||||
}
|
||||
}
|
||||
|
||||
func assertCollectibles(t *testing.T, surface string, row AccountRow, editable string, want []AccountUsername) {
|
||||
t.Helper()
|
||||
if row.Username != editable {
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import (
|
|||
"io/fs"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
|
@ -74,6 +75,7 @@ func (s *server) routes() http.Handler {
|
|||
mux.Handle("GET /api/messages/groups", s.requireAuthAPI(http.HandlerFunc(s.handleGroupMessagesAPI)))
|
||||
mux.Handle("GET /api/messages/groups/detail", s.requireAuthAPI(http.HandlerFunc(s.handleGroupMessageDetailAPI)))
|
||||
mux.Handle("GET /api/collectible-usernames", s.requireAuthAPI(http.HandlerFunc(s.handleCollectibleUsernamesAPI)))
|
||||
mux.Handle("GET /api/reserved-usernames", s.requireAuthAPI(http.HandlerFunc(s.handleReservedUsernamesAPI)))
|
||||
mux.Handle("GET /api/collectible-usernames/{id}", s.requireAuthAPI(http.HandlerFunc(s.handleCollectibleUsernameDetailAPI)))
|
||||
mux.Handle("GET /api/storage/stats", s.requireAuthAPI(http.HandlerFunc(s.handleStorageStatsAPI)))
|
||||
mux.Handle("GET /api/storage/accounts", s.requireAuthAPI(http.HandlerFunc(s.handleStorageAccountsAPI)))
|
||||
|
|
@ -128,6 +130,8 @@ func (s *server) routes() http.Handler {
|
|||
mux.Handle("POST /api/actions/auto-categorize-gif-catalog", s.requireAuthAPI(http.HandlerFunc(s.handleAutoCategorizeGifCatalogAPI)))
|
||||
mux.Handle("POST /api/actions/delete-uncategorized-gifs", s.requireAuthAPI(http.HandlerFunc(s.handleDeleteUncategorizedGifsAPI)))
|
||||
mux.Handle("POST /api/actions/delete-gif-catalog-entry", s.requireAuthAPI(http.HandlerFunc(s.handleDeleteGifCatalogEntryAPI)))
|
||||
mux.Handle("POST /api/actions/reserve-username", s.requireAuthAPI(http.HandlerFunc(s.handleReserveUsernameAPI)))
|
||||
mux.Handle("POST /api/actions/unreserve-username", s.requireAuthAPI(http.HandlerFunc(s.handleUnreserveUsernameAPI)))
|
||||
mux.Handle("POST /api/actions/mint-collectible-username", s.requireAuthAPI(http.HandlerFunc(s.handleMintCollectibleUsernameAPI)))
|
||||
mux.Handle("POST /api/actions/transfer-collectible-username", s.requireAuthAPI(http.HandlerFunc(s.handleTransferCollectibleUsernameAPI)))
|
||||
mux.Handle("POST /api/actions/revoke-collectible-username", s.requireAuthAPI(http.HandlerFunc(s.handleRevokeCollectibleUsernameAPI)))
|
||||
|
|
@ -2239,6 +2243,69 @@ type mintCollectibleUsernameAPIRequest struct {
|
|||
PurchaseDate flexUnix `json:"purchase_date"`
|
||||
}
|
||||
|
||||
type reserveUsernameAPIRequest struct {
|
||||
CommandID string `json:"command_id"`
|
||||
Reason string `json:"reason"`
|
||||
Confirm bool `json:"confirm"`
|
||||
Username string `json:"username"`
|
||||
}
|
||||
|
||||
func (s *server) handleReserveUsernameAPI(w http.ResponseWriter, r *http.Request) {
|
||||
var body reserveUsernameAPIRequest
|
||||
if !decodeAction(w, r, &body) {
|
||||
return
|
||||
}
|
||||
req := admin.ReserveUsernameRequest{
|
||||
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "reserve-username"),
|
||||
Username: body.Username,
|
||||
}
|
||||
result, err := s.callAdminAPI(r.Context(), "/v1/reserved-usernames/reserve", req)
|
||||
writeCommandResultAPI(w, result, err)
|
||||
}
|
||||
|
||||
func (s *server) handleUnreserveUsernameAPI(w http.ResponseWriter, r *http.Request) {
|
||||
var body reserveUsernameAPIRequest
|
||||
if !decodeAction(w, r, &body) {
|
||||
return
|
||||
}
|
||||
req := admin.UnreserveUsernameRequest{
|
||||
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "unreserve-username"),
|
||||
Username: body.Username,
|
||||
}
|
||||
result, err := s.callAdminAPI(r.Context(), "/v1/reserved-usernames/unreserve", req)
|
||||
writeCommandResultAPI(w, result, err)
|
||||
}
|
||||
|
||||
func (s *server) handleReservedUsernamesAPI(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
params := url.Values{}
|
||||
for _, name := range []string{"q", "limit", "offset"} {
|
||||
if v := strings.TrimSpace(q.Get(name)); v != "" {
|
||||
params.Set(name, v)
|
||||
}
|
||||
}
|
||||
apiPath := "/v1/reserved-usernames"
|
||||
if enc := params.Encode(); enc != "" {
|
||||
apiPath += "?" + enc
|
||||
}
|
||||
req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, s.cfg.AdminAPIURL+apiPath, nil)
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusInternalServerError, "request build failed")
|
||||
return
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+s.cfg.AdminAPIToken)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusBadGateway, "admin api unreachable")
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
data, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(resp.StatusCode)
|
||||
_, _ = w.Write(data)
|
||||
}
|
||||
|
||||
func (s *server) handleMintCollectibleUsernameAPI(w http.ResponseWriter, r *http.Request) {
|
||||
var body mintCollectibleUsernameAPIRequest
|
||||
if !decodeAction(w, r, &body) {
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
9
cmd/telesrv-admin/web/dist/assets/index-fn4QJaPB.js
vendored
Normal file
9
cmd/telesrv-admin/web/dist/assets/index-fn4QJaPB.js
vendored
Normal file
File diff suppressed because one or more lines are too long
2
cmd/telesrv-admin/web/dist/index.html
vendored
2
cmd/telesrv-admin/web/dist/index.html
vendored
|
|
@ -23,7 +23,7 @@
|
|||
})();
|
||||
</script>
|
||||
|
||||
<script type="module" crossorigin src="/assets/index-Bt9UBcEE.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-fn4QJaPB.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CQKJMNpu.css">
|
||||
</head>
|
||||
<body>
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import type {
|
|||
ChannelListResponse,
|
||||
CollectibleUsernameDetail,
|
||||
CollectibleUsernameListResponse,
|
||||
ReservedUsernameListResponse,
|
||||
CommandResult,
|
||||
GroupMessageDetail,
|
||||
GroupMessageListResponse,
|
||||
|
|
@ -163,6 +164,8 @@ export const api = {
|
|||
request<CollectibleUsernameListResponse>(`/api/collectible-usernames?${params.toString()}`),
|
||||
collectibleUsername: (id: string) =>
|
||||
request<CollectibleUsernameDetail>(`/api/collectible-usernames/${encodeURIComponent(id)}`),
|
||||
reservedUsernames: (params: URLSearchParams) =>
|
||||
request<ReservedUsernameListResponse>(`/api/reserved-usernames?${params.toString()}`),
|
||||
dashboard: () => request<DashboardResponse>("/api/dashboard"),
|
||||
storageStats: () => request<StorageStatsResponse>("/api/storage/stats"),
|
||||
storageAccounts: (params: URLSearchParams) =>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import {
|
||||
AtSign,
|
||||
BadgeCheck,
|
||||
Ban,
|
||||
Bot,
|
||||
ChevronDown,
|
||||
Database,
|
||||
|
|
@ -99,6 +100,7 @@ export function Shell({
|
|||
<NavLink icon={<Stamp size={16} />} href="/bot-verification" route={route} navigate={navigate}>{"Third-party marks"}</NavLink>
|
||||
)}
|
||||
<NavLink icon={<AtSign size={16} />} href="/collectible-usernames" route={route} navigate={navigate}>{"NFT Usernames"}</NavLink>
|
||||
<NavLink icon={<Ban size={16} />} href="/reserved-usernames" route={route} navigate={navigate}>{"Reserved Usernames"}</NavLink>
|
||||
<NavLink icon={<Database size={16} />} href="/storage" route={route} navigate={navigate}>{"Storage"}</NavLink>
|
||||
<NavLink icon={<Sticker size={16} />} href="/stickers" route={route} navigate={navigate}>{"Stickers"}</NavLink>
|
||||
<NavLink icon={<Smile size={16} />} href="/emoji" route={route} navigate={navigate}>{"Emoji"}</NavLink>
|
||||
|
|
|
|||
176
cmd/telesrv-admin/web/src/pages/ReservedUsernamesPage.tsx
Normal file
176
cmd/telesrv-admin/web/src/pages/ReservedUsernamesPage.tsx
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
import { Loader2, Plus, RefreshCw, Search, Trash2, X } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { api, errorMessage } from "../api";
|
||||
import { ActionButton } from "../components/ActionButton";
|
||||
import { Alert, EmptyRow, Metric, PageFrame, QueryPanel } from "../components/ui";
|
||||
import { formatUnix } from "../lib/format";
|
||||
import type { ReservedUsernameRow } from "../types";
|
||||
|
||||
// Reserved usernames are a plain operator blocklist: a name listed here cannot be
|
||||
// taken as an editable username by any peer and cannot be minted as a
|
||||
// collectible. No owner, no price, no "bought on Fragment" badge - that is the
|
||||
// collectible tab's job.
|
||||
export function ReservedUsernamesPage() {
|
||||
const [q, setQ] = useState("");
|
||||
const [reserveOpen, setReserveOpen] = useState(false);
|
||||
const [rows, setRows] = useState<ReservedUsernameRow[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
const params = new URLSearchParams({ limit: "200" });
|
||||
if (q.trim()) params.set("q", q.trim().replace(/^@/, ""));
|
||||
try {
|
||||
const result = await api.reservedUsernames(params);
|
||||
setRows(result.reserved ?? []);
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<PageFrame
|
||||
title={"Reserved usernames"}
|
||||
eyebrow={"Usernames / Blocklist"}
|
||||
actions={
|
||||
<>
|
||||
<button className="btn primary icon-text" type="button" onClick={() => setReserveOpen(true)}>
|
||||
<Plus size={15} /> {"Reserve username"}
|
||||
</button>
|
||||
<button className="btn icon-text" type="button" onClick={() => load()} disabled={loading}>
|
||||
<RefreshCw size={15} className={loading ? "spin" : ""} /> {"Refresh"}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{error && <Alert>{error}</Alert>}
|
||||
<div className="metric-row">
|
||||
<Metric label={"Reserved names"} value={String(rows.length)} />
|
||||
</div>
|
||||
|
||||
<QueryPanel>
|
||||
<form
|
||||
className="toolbar"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void load();
|
||||
}}
|
||||
>
|
||||
<label className="searchbox">
|
||||
<Search size={15} />
|
||||
<input value={q} onChange={(event) => setQ(event.target.value)} placeholder={"Filter by prefix"} />
|
||||
</label>
|
||||
<button className="btn primary icon-text" type="submit" disabled={loading}>
|
||||
{loading ? <Loader2 size={15} className="spin" /> : <Search size={15} />} {"Search"}
|
||||
</button>
|
||||
</form>
|
||||
</QueryPanel>
|
||||
|
||||
<div className="table-wrap">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{"Username"}</th>
|
||||
<th>{"Reason"}</th>
|
||||
<th>{"Reserved by"}</th>
|
||||
<th>{"Reserved (UTC)"}</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => (
|
||||
<tr key={row.username}>
|
||||
<td><strong>{`@${row.username}`}</strong></td>
|
||||
<td>{row.reason || "-"}</td>
|
||||
<td>{row.actor || "-"}</td>
|
||||
<td>{formatUnix(row.created_at) || "-"}</td>
|
||||
<td>
|
||||
<ActionButton
|
||||
compact
|
||||
label={"Unreserve"}
|
||||
icon={<Trash2 size={13} />}
|
||||
tone="danger"
|
||||
path="/api/actions/unreserve-username"
|
||||
payload={() => ({ username: row.username })}
|
||||
onDone={() => void load()}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{rows.length === 0 && <EmptyRow colSpan={5} />}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{reserveOpen && (
|
||||
<ReserveUsernameModal
|
||||
onClose={() => setReserveOpen(false)}
|
||||
onDone={() => {
|
||||
setReserveOpen(false);
|
||||
void load();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</PageFrame>
|
||||
);
|
||||
}
|
||||
|
||||
// ReserveUsernameModal collects the name, then hands off to ActionButton for the
|
||||
// standard reason / dry-run / confirm flow - the same as every other admin
|
||||
// action. The name is read fresh from state on each ActionButton render.
|
||||
function ReserveUsernameModal({ onClose, onDone }: { onClose: () => void; onDone: () => void }) {
|
||||
const [username, setUsername] = useState("");
|
||||
const clean = username.trim().replace(/^@/, "");
|
||||
|
||||
return createPortal(
|
||||
<div className="modal-backdrop" role="presentation">
|
||||
<section className="modal command-modal" role="dialog" aria-modal="true" aria-label={"Reserve a username"}>
|
||||
<div className="modal-head">
|
||||
<div>
|
||||
<div className="eyebrow">{"Usernames"}</div>
|
||||
<h2>{"Reserve a username"}</h2>
|
||||
</div>
|
||||
<button className="icon-btn" type="button" onClick={onClose} aria-label={"Close"}>
|
||||
<X size={15} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="command-body">
|
||||
<label className="form-field">
|
||||
<span>{"Username"}</span>
|
||||
<input
|
||||
value={username}
|
||||
onChange={(event) => setUsername(event.target.value)}
|
||||
placeholder="support"
|
||||
autoFocus
|
||||
/>
|
||||
</label>
|
||||
<p className="bot-create-note">
|
||||
{`No peer will be able to take @${clean || "…"} until it is unreserved. Nothing is shown to users.`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="modal-actions">
|
||||
<button className="btn" type="button" onClick={onClose}>{"Close"}</button>
|
||||
<ActionButton
|
||||
disabled={clean.length < 5}
|
||||
label={"Reserve username"}
|
||||
icon={<Plus size={15} />}
|
||||
tone="neutral"
|
||||
path="/api/actions/reserve-username"
|
||||
payload={() => ({ username: clean })}
|
||||
onDone={onDone}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import { AccountsPage } from "./AccountsPage";
|
|||
import { SharedDevicesPage } from "./SharedDevicesPage";
|
||||
import { CollectibleUsernameDetailPage } from "./CollectibleUsernameDetailPage";
|
||||
import { CollectibleUsernamesPage } from "./CollectibleUsernamesPage";
|
||||
import { ReservedUsernamesPage } from "./ReservedUsernamesPage";
|
||||
import { ChannelDetailPage } from "./ChannelDetailPage";
|
||||
import { ChannelsPage } from "./ChannelsPage";
|
||||
import { BotDetailPage } from "./BotDetailPage";
|
||||
|
|
@ -82,6 +83,9 @@ export function Routes({ route, navigate }: { route: RouteState; navigate: Navig
|
|||
if (route.path === "/collectible-usernames") {
|
||||
return <CollectibleUsernamesPage navigate={navigate} />;
|
||||
}
|
||||
if (route.path === "/reserved-usernames") {
|
||||
return <ReservedUsernamesPage />;
|
||||
}
|
||||
if (route.path === "/storage") {
|
||||
return <StoragePage navigate={navigate} />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ export function routeTitle(pathname: string): string {
|
|||
if (pathname.startsWith("/bot-verification")) return "Third-party verification";
|
||||
if (pathname.startsWith("/verification")) return "Official Verification";
|
||||
if (pathname.startsWith("/collectible-usernames")) return "Collectible Usernames";
|
||||
if (pathname.startsWith("/reserved-usernames")) return "Reserved Usernames";
|
||||
if (pathname.startsWith("/storage")) return "Storage";
|
||||
if (pathname.startsWith("/accounts/shared-devices")) return "Shared Devices";
|
||||
if (pathname.startsWith("/accounts")) return "Accounts";
|
||||
|
|
|
|||
|
|
@ -347,6 +347,17 @@ export type CollectibleUsernameDetail = {
|
|||
transfers: CollectibleUsernameTransferRow[] | null;
|
||||
};
|
||||
|
||||
export type ReservedUsernameRow = {
|
||||
username: string;
|
||||
reason: string;
|
||||
actor: string;
|
||||
created_at: number;
|
||||
};
|
||||
|
||||
export type ReservedUsernameListResponse = {
|
||||
reserved: ReservedUsernameRow[] | null;
|
||||
};
|
||||
|
||||
// Official platform verification. Every int64 the backend tags `,string` stays a
|
||||
// decimal string here: application ids, peer ids and the optimistic-locking
|
||||
// version all outgrow the exact range of a JSON number, and a rounded version
|
||||
|
|
|
|||
|
|
@ -1007,10 +1007,12 @@ func run(logger *zap.Logger) error {
|
|||
account.WithLoginEmailVerification(codeStore, loginEmailSender, cfg.AuthCodeTTL, cfg.AuthCodeMaxAttempts, cfg.LoginEmailCodeLength))
|
||||
}
|
||||
accountService := account.NewService(passwordStore, accountOptions...)
|
||||
reservedUsernameStore := postgres.NewReservedUsernameStore(pool)
|
||||
botsService := botsapp.NewService(userStore, botStore, messageStore,
|
||||
botsapp.WithLogger(logger.Named("bots")),
|
||||
botsapp.WithBlockChecker(contactStore),
|
||||
botsapp.WithPublicChannelUsernameResolver(channelStore),
|
||||
botsapp.WithReservedUsernames(reservedUsernameStore),
|
||||
botsapp.WithUserCache(userCache),
|
||||
botsapp.WithStickerSetCreator(filesService),
|
||||
botsapp.WithGifCatalogSource(filesService),
|
||||
|
|
@ -1390,6 +1392,7 @@ func run(logger *zap.Logger) error {
|
|||
Emoji: filesService,
|
||||
Moderation: moderationService,
|
||||
Usernames: usernamesService,
|
||||
ReservedUsernames: reservedUsernameStore,
|
||||
Verification: verificationService,
|
||||
BotVerification: botVerificationService,
|
||||
Account: accountService,
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
DROP TABLE IF EXISTS public.reserved_usernames;
|
||||
17
deploy/migrations/20260909190000_reserved_usernames.up.sql
Normal file
17
deploy/migrations/20260909190000_reserved_usernames.up.sql
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
-- Operator-maintained username blocklist. A name listed here cannot be taken as
|
||||
-- an editable username by any peer (account.updateUsername, channels.updateUsername,
|
||||
-- @BotFather /setusername, or the admin set-username actions). It is a plain
|
||||
-- blocklist: no owner, no price, no Fragment collectible badge.
|
||||
|
||||
CREATE TABLE public.reserved_usernames (
|
||||
username_lower text PRIMARY KEY CHECK (
|
||||
username_lower <> '' AND username_lower = lower(username_lower)
|
||||
),
|
||||
username text NOT NULL,
|
||||
reason text NOT NULL DEFAULT '' CHECK (octet_length(reason) <= 512),
|
||||
actor text NOT NULL DEFAULT '' CHECK (octet_length(actor) <= 256),
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX reserved_usernames_created_at_idx
|
||||
ON public.reserved_usernames (created_at DESC, username_lower);
|
||||
|
|
@ -66,6 +66,9 @@ const (
|
|||
ActionTransferCollectibleUsername = "usernames.collectible.transfer"
|
||||
ActionRevokeCollectibleUsername = "usernames.collectible.revoke"
|
||||
ActionDeleteCollectibleUsername = "usernames.collectible.delete"
|
||||
// Operator username blocklist.
|
||||
ActionReserveUsername = "usernames.reserve"
|
||||
ActionUnreserveUsername = "usernames.unreserve"
|
||||
// Official platform verification review. Claim/approve/reject act on one
|
||||
// application; revoke acts on a target, because clearing a badge is not a
|
||||
// decision on the application that granted it.
|
||||
|
|
@ -363,6 +366,16 @@ type CollectibleUsernamesService interface {
|
|||
Transfers(ctx context.Context, collectibleID int64, limit int) ([]domain.CollectibleUsernameTransfer, error)
|
||||
}
|
||||
|
||||
// ReservedUsernamesService is the operator username blocklist: a plain list of
|
||||
// names no peer may take. Separate from the collectible lifecycle - a reservation
|
||||
// has no owner, no price and no Fragment badge.
|
||||
type ReservedUsernamesService interface {
|
||||
IsReserved(ctx context.Context, usernameLower string) (bool, error)
|
||||
ReserveUsername(ctx context.Context, username, reason, actor string) (created bool, err error)
|
||||
UnreserveUsername(ctx context.Context, username string) (removed bool, err error)
|
||||
ReservedUsernames(ctx context.Context, filter domain.ReservedUsernameFilter) ([]domain.ReservedUsername, error)
|
||||
}
|
||||
|
||||
// collectibleUsernameByIDLookup is the optional by-identity read. Stores that
|
||||
// expose it answer a detail request in one round trip; the keyset fallback in
|
||||
// CollectibleUsernameByID keeps a service without it correct.
|
||||
|
|
@ -389,6 +402,7 @@ type Dependencies struct {
|
|||
Emoji EmojiService
|
||||
Moderation ModerationService
|
||||
Usernames CollectibleUsernamesService
|
||||
ReservedUsernames ReservedUsernamesService
|
||||
Verification VerificationService
|
||||
// BotVerification is the third-party mechanism, wired separately from
|
||||
// Verification: the two never read each other's state.
|
||||
|
|
@ -420,6 +434,7 @@ type Service struct {
|
|||
emoji EmojiService
|
||||
moderation ModerationService
|
||||
usernames CollectibleUsernamesService
|
||||
reservedUsernames ReservedUsernamesService
|
||||
verification VerificationService
|
||||
botVerification BotVerificationService
|
||||
account AccountService
|
||||
|
|
@ -487,6 +502,9 @@ func (s *Service) Configure(deps Dependencies) *Service {
|
|||
if deps.Usernames != nil {
|
||||
s.usernames = deps.Usernames
|
||||
}
|
||||
if deps.ReservedUsernames != nil {
|
||||
s.reservedUsernames = deps.ReservedUsernames
|
||||
}
|
||||
if deps.Verification != nil {
|
||||
s.verification = deps.Verification
|
||||
}
|
||||
|
|
@ -2059,6 +2077,91 @@ func (s *Service) DeleteCollectibleUsername(ctx context.Context, req DeleteColle
|
|||
})
|
||||
}
|
||||
|
||||
// ReserveUsernameRequest / UnreserveUsernameRequest add or remove a blocklist
|
||||
// entry. reservedUsernameFromRequest normalises the name; the reason is a free
|
||||
// operator note.
|
||||
type ReserveUsernameRequest struct {
|
||||
CommandMeta
|
||||
Username string
|
||||
}
|
||||
|
||||
type UnreserveUsernameRequest struct {
|
||||
CommandMeta
|
||||
Username string
|
||||
}
|
||||
|
||||
// ReserveUsername adds a name to the operator blocklist. Journalled and
|
||||
// replay-safe like every other command.
|
||||
func (s *Service) ReserveUsername(ctx context.Context, req ReserveUsernameRequest) (CommandResult, error) {
|
||||
if s == nil || s.reservedUsernames == nil {
|
||||
return CommandResult{}, fmt.Errorf("admin reserved username dependency is not configured")
|
||||
}
|
||||
req.Username = domain.NormalizeUsername(req.Username)
|
||||
if !domain.ValidCollectibleUsername(req.Username) {
|
||||
return CommandResult{}, codedError(CodeUsernameInvalid, domain.ErrUsernameInvalid)
|
||||
}
|
||||
if len(req.Reason) > domain.MaxReservedUsernameReasonLength {
|
||||
return CommandResult{}, fmt.Errorf("reason must be <= %d bytes", domain.MaxReservedUsernameReasonLength)
|
||||
}
|
||||
return s.runCommand(ctx, req.CommandMeta, ActionReserveUsername, 0, domain.Peer{}, req, func() (CommandResult, error) {
|
||||
details := map[string]any{"username": req.Username}
|
||||
if s.usernames != nil {
|
||||
if asset, err := s.usernames.Collectible(ctx, req.Username); err == nil {
|
||||
details["existing_collectible_id"] = strconv.FormatInt(asset.ID, 10)
|
||||
return CommandResult{Details: details}, codedError(CodeUsernameOccupied, domain.ErrUsernameOccupied)
|
||||
}
|
||||
}
|
||||
if req.DryRun {
|
||||
return CommandResult{Message: "username reservation validated", Details: details}, nil
|
||||
}
|
||||
created, err := s.reservedUsernames.ReserveUsername(ctx, req.Username, req.Reason, req.Actor)
|
||||
if err != nil {
|
||||
return CommandResult{Details: details}, err
|
||||
}
|
||||
details["created"] = created
|
||||
message := "username reserved"
|
||||
if !created {
|
||||
message = "username was already reserved"
|
||||
}
|
||||
return CommandResult{Message: message, Details: details}, nil
|
||||
})
|
||||
}
|
||||
|
||||
// UnreserveUsername removes a name from the operator blocklist.
|
||||
func (s *Service) UnreserveUsername(ctx context.Context, req UnreserveUsernameRequest) (CommandResult, error) {
|
||||
if s == nil || s.reservedUsernames == nil {
|
||||
return CommandResult{}, fmt.Errorf("admin reserved username dependency is not configured")
|
||||
}
|
||||
req.Username = domain.NormalizeUsername(req.Username)
|
||||
if strings.TrimSpace(req.Username) == "" {
|
||||
return CommandResult{}, codedError(CodeUsernameInvalid, domain.ErrUsernameInvalid)
|
||||
}
|
||||
return s.runCommand(ctx, req.CommandMeta, ActionUnreserveUsername, 0, domain.Peer{}, req, func() (CommandResult, error) {
|
||||
details := map[string]any{"username": req.Username}
|
||||
if req.DryRun {
|
||||
return CommandResult{Message: "username unreservation validated", Details: details}, nil
|
||||
}
|
||||
removed, err := s.reservedUsernames.UnreserveUsername(ctx, req.Username)
|
||||
if err != nil {
|
||||
return CommandResult{Details: details}, err
|
||||
}
|
||||
details["removed"] = removed
|
||||
message := "username unreserved"
|
||||
if !removed {
|
||||
message = "username was not reserved"
|
||||
}
|
||||
return CommandResult{Message: message, Details: details}, nil
|
||||
})
|
||||
}
|
||||
|
||||
// ReservedUsernames is the admin listing read for the blocklist.
|
||||
func (s *Service) ReservedUsernames(ctx context.Context, filter domain.ReservedUsernameFilter) ([]domain.ReservedUsername, error) {
|
||||
if s == nil || s.reservedUsernames == nil {
|
||||
return nil, fmt.Errorf("reserved username dependency is not configured")
|
||||
}
|
||||
return s.reservedUsernames.ReservedUsernames(ctx, filter)
|
||||
}
|
||||
|
||||
func collectibleOwnerPeer(userID, channelID int64) (domain.Peer, error) {
|
||||
if userID < 0 || channelID < 0 {
|
||||
return domain.Peer{}, fmt.Errorf("owner id must be positive")
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import (
|
|||
|
||||
usernamesapp "telesrv/internal/app/usernames"
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
// Compile-time proof that the shipped use-case services satisfy the admin ports.
|
||||
|
|
@ -1427,3 +1428,76 @@ func TestDeleteCollectibleUsernameCommand(t *testing.T) {
|
|||
t.Fatalf("delete of invalid name = nil error, want rejection")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReserveAndUnreserveUsername(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
reserved := memory.NewReservedUsernameStore()
|
||||
svc := NewService(Dependencies{
|
||||
Commands: newMemoryCommandRepo(),
|
||||
ReservedUsernames: reserved,
|
||||
Now: fixedNow,
|
||||
})
|
||||
|
||||
dry, err := svc.ReserveUsername(ctx, ReserveUsernameRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "rv-dry", Actor: "ops", Reason: "official handle", DryRun: true},
|
||||
Username: "@Support",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("dry-run reserve: %v", err)
|
||||
}
|
||||
if got, _ := reserved.IsReserved(ctx, "support"); got {
|
||||
t.Fatal("dry-run reserved the name")
|
||||
}
|
||||
if dry.Details["username"] != "Support" {
|
||||
t.Fatalf("dry-run details = %+v", dry.Details)
|
||||
}
|
||||
|
||||
if _, err := svc.ReserveUsername(ctx, ReserveUsernameRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "rv-exec", Actor: "ops", Reason: "official handle"},
|
||||
Username: "support",
|
||||
}); err != nil {
|
||||
t.Fatalf("reserve: %v", err)
|
||||
}
|
||||
if got, _ := reserved.IsReserved(ctx, "support"); !got {
|
||||
t.Fatal("name not reserved after exec")
|
||||
}
|
||||
|
||||
if _, err := svc.UnreserveUsername(ctx, UnreserveUsernameRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "urv-exec", Actor: "ops", Reason: "no longer needed"},
|
||||
Username: "SUPPORT",
|
||||
}); err != nil {
|
||||
t.Fatalf("unreserve: %v", err)
|
||||
}
|
||||
if got, _ := reserved.IsReserved(ctx, "support"); got {
|
||||
t.Fatal("name still reserved after unreserve")
|
||||
}
|
||||
|
||||
if _, err := svc.ReserveUsername(ctx, ReserveUsernameRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "bad", Actor: "ops", Reason: "x"},
|
||||
Username: "ab",
|
||||
}); err == nil {
|
||||
t.Fatal("reserve of a too-short name = nil error, want rejection")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemoryRegistryRefusesReservedName(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
reserved := memory.NewReservedUsernameStore()
|
||||
if _, err := reserved.ReserveUsername(ctx, "support", "", "ops"); err != nil {
|
||||
t.Fatalf("seed reserve: %v", err)
|
||||
}
|
||||
registry := memory.NewCollectibleUsernameStore().WithReservedUsernames(reserved)
|
||||
|
||||
if _, err := registry.SetEditableUsername(ctx, domain.Peer{Type: domain.PeerTypeUser, ID: 1}, "support"); !errors.Is(err, domain.ErrUsernameOccupied) {
|
||||
t.Fatalf("SetEditableUsername(reserved) err = %v, want ErrUsernameOccupied", err)
|
||||
}
|
||||
if _, _, err := registry.MintCollectibleUsername(ctx, domain.MintCollectibleUsernameRequest{
|
||||
Username: "support", Currency: domain.CollectibleCurrencyUSD, Amount: 0, CommandKey: "k1",
|
||||
}); !errors.Is(err, domain.ErrUsernameOccupied) {
|
||||
t.Fatalf("Mint(reserved) err = %v, want ErrUsernameOccupied", err)
|
||||
}
|
||||
// A different name is unaffected.
|
||||
if _, err := registry.SetEditableUsername(ctx, domain.Peer{Type: domain.PeerTypeUser, ID: 1}, "freename"); err != nil {
|
||||
t.Fatalf("SetEditableUsername(free) err = %v", err)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -98,6 +98,9 @@ type Service interface {
|
|||
CollectibleUsernames(ctx context.Context, filter domain.CollectibleUsernameFilter) ([]domain.CollectibleUsername, error)
|
||||
CollectibleUsernameByID(ctx context.Context, id int64) (domain.CollectibleUsername, error)
|
||||
CollectibleUsernameTransfers(ctx context.Context, collectibleID int64, limit int) ([]domain.CollectibleUsernameTransfer, error)
|
||||
ReserveUsername(ctx context.Context, req admin.ReserveUsernameRequest) (admin.CommandResult, error)
|
||||
UnreserveUsername(ctx context.Context, req admin.UnreserveUsernameRequest) (admin.CommandResult, error)
|
||||
ReservedUsernames(ctx context.Context, filter domain.ReservedUsernameFilter) ([]domain.ReservedUsername, error)
|
||||
ClaimVerification(ctx context.Context, req admin.ClaimVerificationRequest) (admin.CommandResult, error)
|
||||
ApproveVerification(ctx context.Context, req admin.ApproveVerificationRequest) (admin.CommandResult, error)
|
||||
RejectVerification(ctx context.Context, req admin.RejectVerificationRequest) (admin.CommandResult, error)
|
||||
|
|
@ -234,6 +237,9 @@ func (s *Server) routes() http.Handler {
|
|||
mux.HandleFunc("POST /v1/collectible-usernames/delete", s.authenticated(s.handleDeleteCollectibleUsername))
|
||||
mux.HandleFunc("GET /v1/collectible-usernames", s.authenticated(s.handleCollectibleUsernames))
|
||||
mux.HandleFunc("GET /v1/collectible-usernames/{id}", s.authenticated(s.handleCollectibleUsername))
|
||||
mux.HandleFunc("POST /v1/reserved-usernames/reserve", s.authenticated(s.handleReserveUsername))
|
||||
mux.HandleFunc("POST /v1/reserved-usernames/unreserve", s.authenticated(s.handleUnreserveUsername))
|
||||
mux.HandleFunc("GET /v1/reserved-usernames", s.authenticated(s.handleReservedUsernames))
|
||||
// Official platform verification. Unlike every route above, these carry a
|
||||
// named permission, so a scoped token can be given the review surface and
|
||||
// nothing else. Revocation additionally requires verification.revoke.
|
||||
|
|
@ -1186,6 +1192,54 @@ func (s *Server) handleDeleteCollectibleUsername(w http.ResponseWriter, r *http.
|
|||
writeCommandResult(w, result, err)
|
||||
}
|
||||
|
||||
func (s *Server) handleReserveUsername(w http.ResponseWriter, r *http.Request) {
|
||||
var req admin.ReserveUsernameRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
result, err := s.svc.ReserveUsername(r.Context(), req)
|
||||
writeCommandResult(w, result, err)
|
||||
}
|
||||
|
||||
func (s *Server) handleUnreserveUsername(w http.ResponseWriter, r *http.Request) {
|
||||
var req admin.UnreserveUsernameRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
result, err := s.svc.UnreserveUsername(r.Context(), req)
|
||||
writeCommandResult(w, result, err)
|
||||
}
|
||||
|
||||
func (s *Server) handleReservedUsernames(w http.ResponseWriter, r *http.Request) {
|
||||
query := r.URL.Query()
|
||||
filter := domain.ReservedUsernameFilter{Query: query.Get("q")}
|
||||
limit, ok := optionalQueryInt(w, query, "limit")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
filter.Limit = limit
|
||||
offset, ok := optionalQueryInt(w, query, "offset")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
filter.Offset = offset
|
||||
items, err := s.svc.ReservedUsernames(r.Context(), filter)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "list failed")
|
||||
return
|
||||
}
|
||||
out := make([]map[string]any, 0, len(items))
|
||||
for _, item := range items {
|
||||
out = append(out, map[string]any{
|
||||
"username": item.Username,
|
||||
"reason": item.Reason,
|
||||
"actor": item.Actor,
|
||||
"created_at": item.CreatedAt.Unix(),
|
||||
})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"reserved": out})
|
||||
}
|
||||
|
||||
func (s *Server) handleCollectibleUsernames(w http.ResponseWriter, r *http.Request) {
|
||||
query := r.URL.Query()
|
||||
filter := domain.CollectibleUsernameFilter{
|
||||
|
|
|
|||
|
|
@ -514,10 +514,28 @@ type captureCollectibleUsernameService struct {
|
|||
transfer admin.TransferCollectibleUsernameRequest
|
||||
revoke admin.RevokeCollectibleUsernameRequest
|
||||
del admin.DeleteCollectibleUsernameRequest
|
||||
reserve admin.ReserveUsernameRequest
|
||||
unreserve admin.UnreserveUsernameRequest
|
||||
resFilter domain.ReservedUsernameFilter
|
||||
filter domain.CollectibleUsernameFilter
|
||||
assetID int64
|
||||
}
|
||||
|
||||
func (s *captureCollectibleUsernameService) ReserveUsername(_ context.Context, req admin.ReserveUsernameRequest) (admin.CommandResult, error) {
|
||||
s.reserve = req
|
||||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||
}
|
||||
|
||||
func (s *captureCollectibleUsernameService) UnreserveUsername(_ context.Context, req admin.UnreserveUsernameRequest) (admin.CommandResult, error) {
|
||||
s.unreserve = req
|
||||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||
}
|
||||
|
||||
func (s *captureCollectibleUsernameService) ReservedUsernames(_ context.Context, filter domain.ReservedUsernameFilter) ([]domain.ReservedUsername, error) {
|
||||
s.resFilter = filter
|
||||
return []domain.ReservedUsername{{Username: "support", Reason: "official", Actor: "ops"}}, nil
|
||||
}
|
||||
|
||||
func (s *captureCollectibleUsernameService) MintCollectibleUsername(_ context.Context, req admin.MintCollectibleUsernameRequest) (admin.CommandResult, error) {
|
||||
s.mint = req
|
||||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||
|
|
@ -731,3 +749,49 @@ func (fakeService) CollectibleUsernameByID(context.Context, int64) (domain.Colle
|
|||
func (fakeService) CollectibleUsernameTransfers(context.Context, int64, int) ([]domain.CollectibleUsernameTransfer, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (fakeService) ReserveUsername(_ context.Context, req admin.ReserveUsernameRequest) (admin.CommandResult, error) {
|
||||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||
}
|
||||
|
||||
func (fakeService) UnreserveUsername(_ context.Context, req admin.UnreserveUsernameRequest) (admin.CommandResult, error) {
|
||||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||
}
|
||||
|
||||
func (fakeService) ReservedUsernames(context.Context, domain.ReservedUsernameFilter) ([]domain.ReservedUsername, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func TestAdminAPIReservedUsernames(t *testing.T) {
|
||||
svc := &captureCollectibleUsernameService{}
|
||||
srv := &Server{token: "secret", svc: svc}
|
||||
|
||||
reserve := httptest.NewRequest(http.MethodPost, "/v1/reserved-usernames/reserve", strings.NewReader(
|
||||
`{"command_id":"r-1","actor":"ops","reason":"official","username":"support"}`))
|
||||
reserve.Header.Set("Authorization", "Bearer secret")
|
||||
rec := httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, reserve)
|
||||
if rec.Code != http.StatusOK || svc.reserve.Username != "support" || svc.reserve.CommandID != "r-1" {
|
||||
t.Fatalf("reserve status=%d req=%+v", rec.Code, svc.reserve)
|
||||
}
|
||||
|
||||
unreserve := httptest.NewRequest(http.MethodPost, "/v1/reserved-usernames/unreserve", strings.NewReader(
|
||||
`{"command_id":"u-1","actor":"ops","reason":"done","username":"support"}`))
|
||||
unreserve.Header.Set("Authorization", "Bearer secret")
|
||||
rec = httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, unreserve)
|
||||
if rec.Code != http.StatusOK || svc.unreserve.Username != "support" {
|
||||
t.Fatalf("unreserve status=%d req=%+v", rec.Code, svc.unreserve)
|
||||
}
|
||||
|
||||
list := httptest.NewRequest(http.MethodGet, "/v1/reserved-usernames?q=sup&limit=10", nil)
|
||||
list.Header.Set("Authorization", "Bearer secret")
|
||||
rec = httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, list)
|
||||
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"username":"support"`) {
|
||||
t.Fatalf("list status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if svc.resFilter.Query != "sup" || svc.resFilter.Limit != 10 {
|
||||
t.Fatalf("list filter = %+v", svc.resFilter)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -274,6 +274,13 @@ func (s *Service) handleBotFather(ctx context.Context, userID int64, msg domain.
|
|||
if cmd, ok := parseBotCommand(text); ok {
|
||||
inValueStep := found && state.Step == botFatherStepValue
|
||||
if !inValueStep || botFatherGlobalCommands[cmd] {
|
||||
// "/start <bot>" (the "Manage Bot" deep link) jumps straight to that
|
||||
// bot's menu, like /mybots then tapping the bot.
|
||||
if cmd == "start" {
|
||||
if arg := botCommandArg(text); arg != "" {
|
||||
return s.handleBotFatherStart(ctx, userID, arg)
|
||||
}
|
||||
}
|
||||
return s.handleBotFatherCommand(ctx, userID, cmd)
|
||||
}
|
||||
}
|
||||
|
|
@ -368,6 +375,41 @@ func (s *Service) stepPrompt(state domain.BotChatState) botReply {
|
|||
}
|
||||
}
|
||||
|
||||
// handleBotFatherStart answers "/start <arg>". When <arg> names one of the
|
||||
// user's own bots (by username or numeric id) it opens that bot's menu - the
|
||||
// same "What do you want to do?" screen as /mybots then tapping the bot, which
|
||||
// is what the "Manage Bot" button on a bot's profile links to. An empty or
|
||||
// unknown arg falls back to the plain greeting.
|
||||
func (s *Service) handleBotFatherStart(ctx context.Context, userID int64, arg string) botReply {
|
||||
_ = s.bots.DeleteBotChatState(ctx, domain.BotFatherUserID, userID)
|
||||
want := strings.ToLower(strings.TrimPrefix(strings.TrimSpace(arg), "@"))
|
||||
if want == "" {
|
||||
return botReply{Text: botFatherHelpText}
|
||||
}
|
||||
owned, err := s.ownedBots(ctx, userID)
|
||||
if err != nil {
|
||||
s.log.Error("botfather: list bots for start payload", zap.Int64("user_id", userID), zap.Error(err))
|
||||
return internalReply()
|
||||
}
|
||||
for _, b := range owned {
|
||||
if strings.EqualFold(b.user.Username, want) || strconv.FormatInt(b.user.ID, 10) == want {
|
||||
state := domain.BotChatState{
|
||||
BotUserID: domain.BotFatherUserID,
|
||||
UserID: userID,
|
||||
Command: mybotsCommand,
|
||||
Step: mybotsStepMenu,
|
||||
Draft: map[string]string{},
|
||||
}
|
||||
reply := s.myBotsBotMenu(&state, b)
|
||||
if !s.saveMyBotsState(ctx, state) {
|
||||
return internalReply()
|
||||
}
|
||||
return reply
|
||||
}
|
||||
}
|
||||
return botReply{Text: botFatherHelpText}
|
||||
}
|
||||
|
||||
func (s *Service) handleBotFatherCommand(ctx context.Context, userID int64, cmd string) botReply {
|
||||
switch cmd {
|
||||
case "start", "help":
|
||||
|
|
@ -1157,3 +1199,16 @@ func parseBotCommand(text string) (string, bool) {
|
|||
}
|
||||
return strings.ToLower(cmd), true
|
||||
}
|
||||
|
||||
// botCommandArg returns the trimmed argument after a leading "/cmd", e.g.
|
||||
// "/start my_bot" -> "my_bot". Empty when there is no argument.
|
||||
func botCommandArg(text string) string {
|
||||
text = strings.TrimSpace(text)
|
||||
if !strings.HasPrefix(text, "/") {
|
||||
return ""
|
||||
}
|
||||
if i := strings.IndexAny(text, " \t\n"); i >= 0 {
|
||||
return strings.TrimSpace(text[i+1:])
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -202,6 +202,40 @@ func TestMyBotsBotMenuAndBack(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestBotFatherStartWithBotOpensItsMenu(t *testing.T) {
|
||||
svc, users, _, messages := newTestService(t)
|
||||
owner := newOwner(t, users, "+2011")
|
||||
makeBots(t, svc, owner.ID, 2)
|
||||
|
||||
// "/start <bot>" is the "Manage Bot" deep link: it lands on the per-bot menu.
|
||||
body := sendToBotFather(t, svc, messages, owner, "/start mb0_bot")
|
||||
if !strings.Contains(body, "@mb0_bot") || !strings.Contains(body, "What do you want to do?") {
|
||||
t.Fatalf("/start mb0_bot reply = %q", body)
|
||||
}
|
||||
menu := botFatherUserReply(t, messages, owner.ID)
|
||||
for _, want := range []string{"API Token", "Edit Bot", "Bot Settings", "Delete Bot", "Back to bots"} {
|
||||
if !mybotsHasButton(menu, want) {
|
||||
t.Fatalf("start menu missing %q: %+v", want, menu.ReplyMarkup)
|
||||
}
|
||||
}
|
||||
// The buttons are live (state was saved), so Edit Bot works from here.
|
||||
_, edit := pressBotFather(t, svc, messages, owner.ID, "Edit Bot")
|
||||
if !strings.Contains(edit.Body, "@mb0_bot") {
|
||||
t.Fatalf("edit menu after /start = %q", edit.Body)
|
||||
}
|
||||
|
||||
// A leading @ and an unknown/foreign bot fall back to the greeting.
|
||||
if body := sendToBotFather(t, svc, messages, owner, "/start @mb1_bot"); !strings.Contains(body, "@mb1_bot") {
|
||||
t.Fatalf("/start @mb1_bot reply = %q", body)
|
||||
}
|
||||
if body := sendToBotFather(t, svc, messages, owner, "/start not_a_real_bot"); !strings.Contains(body, "create a new bot") {
|
||||
t.Fatalf("/start unknown reply = %q, want greeting", body)
|
||||
}
|
||||
if body := sendToBotFather(t, svc, messages, owner, "/start"); !strings.Contains(body, "create a new bot") {
|
||||
t.Fatalf("bare /start reply = %q, want greeting", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMyBotsTokenAndRevoke(t *testing.T) {
|
||||
svc, users, bots, messages := newTestService(t)
|
||||
owner := newOwner(t, users, "+2003")
|
||||
|
|
|
|||
|
|
@ -121,6 +121,7 @@ type Service struct {
|
|||
messages store.MessageStore
|
||||
blocker blockChecker
|
||||
channels publicChannelUsernameResolver
|
||||
reserved reservedUsernameChecker
|
||||
stickers stickerSetCreator
|
||||
installer userStickerSetInstaller
|
||||
aiChat aiChatGenerator
|
||||
|
|
@ -199,6 +200,21 @@ func WithBotAvatarStore(a botAvatarStore) Option {
|
|||
|
||||
// WithPublicChannelUsernameResolver 注入公开频道 username 查询能力,用于 bot
|
||||
// username 预检,避免 bot 与 public channel 产生同名可见入口。
|
||||
// reservedUsernameChecker reports whether a name is on the operator blocklist.
|
||||
type reservedUsernameChecker interface {
|
||||
IsReserved(ctx context.Context, usernameLower string) (bool, error)
|
||||
}
|
||||
|
||||
// WithReservedUsernames wires the operator username blocklist so CheckUsername
|
||||
// reports a reserved bot name as taken instead of available.
|
||||
func WithReservedUsernames(c reservedUsernameChecker) Option {
|
||||
return func(s *Service) {
|
||||
if c != nil {
|
||||
s.reserved = c
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func WithPublicChannelUsernameResolver(c publicChannelUsernameResolver) Option {
|
||||
return func(s *Service) {
|
||||
if c != nil {
|
||||
|
|
@ -533,6 +549,13 @@ func (s *Service) CheckUsername(ctx context.Context, ownerUserID int64, username
|
|||
if !domain.ValidBotUsername(username) {
|
||||
return false, domain.ErrBotUsernameInvalid
|
||||
}
|
||||
if s.reserved != nil {
|
||||
if r, err := s.reserved.IsReserved(ctx, strings.ToLower(username)); err != nil {
|
||||
return false, err
|
||||
} else if r {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
if _, found, err := s.users.ByUsername(ctx, username); err != nil {
|
||||
return false, err
|
||||
} else if found {
|
||||
|
|
|
|||
|
|
@ -64,9 +64,6 @@ func (c *participantsReadModelCache) invalidateChannel(channelID int64) {
|
|||
|
||||
func (s *Service) cachedParticipants(ctx context.Context, userID, channelID int64, filter domain.ChannelParticipantsFilter, offset, limit int) (domain.ChannelParticipantList, error) {
|
||||
filter, offset, limit = normalizeParticipantsRequest(filter, offset, limit)
|
||||
if s.participantCache == nil || s.versions == nil {
|
||||
return s.loadParticipants(ctx, userID, channelID, filter, offset, limit)
|
||||
}
|
||||
key := participantsCacheKey{
|
||||
userID: userID,
|
||||
channelID: channelID,
|
||||
|
|
@ -75,15 +72,23 @@ func (s *Service) cachedParticipants(ctx context.Context, userID, channelID int6
|
|||
offset: offset,
|
||||
limit: limit,
|
||||
}
|
||||
if s.participantCache == nil || s.versions == nil {
|
||||
return s.loadParticipantsWithContentHash(ctx, userID, channelID, filter, key)
|
||||
}
|
||||
hash, err := s.channelParticipantsHash(ctx, userID, channelID, key)
|
||||
if err != nil {
|
||||
return domain.ChannelParticipantList{}, err
|
||||
}
|
||||
if hash == 0 {
|
||||
return s.loadParticipants(ctx, userID, channelID, filter, offset, limit)
|
||||
// The read-model version hash is unavailable (e.g. a channel whose
|
||||
// read_model_versions rows were never seeded). Fall back to a stable
|
||||
// content hash so the RPC layer can still answer
|
||||
// channels.channelParticipantsNotModified. Without a non-zero, stable
|
||||
// Hash a client that polls the member list re-fetches it forever.
|
||||
return s.loadParticipantsWithContentHash(ctx, userID, channelID, filter, key)
|
||||
}
|
||||
return s.participantCache.getOrLoad(ctx, key, hash, func() (domain.ChannelParticipantList, error) {
|
||||
list, err := s.loadParticipants(ctx, userID, channelID, filter, offset, limit)
|
||||
list, err := s.loadParticipants(ctx, userID, channelID, filter, key.offset, key.limit)
|
||||
if err != nil {
|
||||
return domain.ChannelParticipantList{}, err
|
||||
}
|
||||
|
|
@ -92,6 +97,54 @@ func (s *Service) cachedParticipants(ctx context.Context, userID, channelID int6
|
|||
})
|
||||
}
|
||||
|
||||
// loadParticipantsWithContentHash loads a participants page and, when nothing has
|
||||
// assigned an opaque version hash, derives a deterministic one from the page's
|
||||
// own contents so identical results keep producing an identical Hash.
|
||||
func (s *Service) loadParticipantsWithContentHash(ctx context.Context, userID, channelID int64, filter domain.ChannelParticipantsFilter, key participantsCacheKey) (domain.ChannelParticipantList, error) {
|
||||
list, err := s.loadParticipants(ctx, userID, channelID, filter, key.offset, key.limit)
|
||||
if err != nil {
|
||||
return domain.ChannelParticipantList{}, err
|
||||
}
|
||||
if list.Hash == 0 {
|
||||
list.Hash = participantsContentHash(channelID, key, list)
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
|
||||
// participantsContentHash is a stable fingerprint of a participants page: the
|
||||
// channel, the page key and every returned member's client-visible identity
|
||||
// (id, role, rank, status). Any change a client would render (a new member, a
|
||||
// promotion, a rank edit, a kick) changes the hash; an unchanged page does not.
|
||||
func participantsContentHash(channelID int64, key participantsCacheKey, list domain.ChannelParticipantList) int64 {
|
||||
h := fnv.New64a()
|
||||
var buf [8]byte
|
||||
writeUint := func(v uint64) {
|
||||
binary.LittleEndian.PutUint64(buf[:], v)
|
||||
_, _ = h.Write(buf[:])
|
||||
}
|
||||
writeStr := func(s string) {
|
||||
_, _ = h.Write([]byte(s))
|
||||
_, _ = h.Write([]byte{0})
|
||||
}
|
||||
writeUint(uint64(channelID))
|
||||
writeStr(string(key.kind))
|
||||
writeStr(key.query)
|
||||
writeUint(uint64(key.offset))
|
||||
writeUint(uint64(key.limit))
|
||||
writeUint(uint64(int64(list.Count)))
|
||||
for _, p := range list.Participants {
|
||||
writeUint(uint64(p.UserID))
|
||||
writeStr(string(p.Role))
|
||||
writeStr(string(p.Status))
|
||||
writeStr(p.Rank)
|
||||
}
|
||||
sum := int64(h.Sum64() & 0x7fffffffffffffff)
|
||||
if sum == 0 {
|
||||
return 1
|
||||
}
|
||||
return sum
|
||||
}
|
||||
|
||||
func (s *Service) loadParticipants(ctx context.Context, userID, channelID int64, filter domain.ChannelParticipantsFilter, offset, limit int) (domain.ChannelParticipantList, error) {
|
||||
if filter.Kind == domain.ChannelParticipantsBots && s.bots != nil {
|
||||
return s.getBotParticipants(ctx, userID, channelID, offset, limit)
|
||||
|
|
|
|||
|
|
@ -814,6 +814,51 @@ func TestGetParticipantsCacheInvalidatesAfterAdminMutation(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestGetParticipantsFallsBackToContentHashWithoutReadModelVersions(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
const ownerID int64 = 1001
|
||||
base := &countingChannelStore{ChannelStore: memory.NewChannelStore()}
|
||||
// No WithReadModelVersions: channelParticipantsHash can never build an opaque
|
||||
// version hash, so the service must derive a stable one from the page itself.
|
||||
service := NewService(base)
|
||||
created, err := service.CreateChannel(ctx, ownerID, domain.CreateChannelRequest{
|
||||
Title: "Fallback Hash",
|
||||
Megagroup: true,
|
||||
MemberUserIDs: []int64{1002},
|
||||
Date: 1700004105,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateChannel: %v", err)
|
||||
}
|
||||
filter := domain.ChannelParticipantsFilter{Kind: domain.ChannelParticipantsRecent}
|
||||
|
||||
first, err := service.GetParticipants(ctx, ownerID, created.Channel.ID, filter, 0, 20)
|
||||
if err != nil {
|
||||
t.Fatalf("first participants: %v", err)
|
||||
}
|
||||
if first.Hash == 0 {
|
||||
t.Fatalf("first participants hash = 0, want stable non-zero fallback")
|
||||
}
|
||||
second, err := service.GetParticipants(ctx, ownerID, created.Channel.ID, filter, 0, 20)
|
||||
if err != nil {
|
||||
t.Fatalf("second participants: %v", err)
|
||||
}
|
||||
if second.Hash != first.Hash {
|
||||
t.Fatalf("second hash = %d, want stable %d", second.Hash, first.Hash)
|
||||
}
|
||||
|
||||
if _, err := service.InviteToChannel(ctx, ownerID, created.Channel.ID, []int64{1003}, 1700004106); err != nil {
|
||||
t.Fatalf("InviteToChannel: %v", err)
|
||||
}
|
||||
third, err := service.GetParticipants(ctx, ownerID, created.Channel.ID, filter, 0, 20)
|
||||
if err != nil {
|
||||
t.Fatalf("third participants: %v", err)
|
||||
}
|
||||
if third.Hash == first.Hash {
|
||||
t.Fatalf("third hash = %d, want changed after a new member joined", third.Hash)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFullMegagroupAdminGrantFillsManageRanks(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
service := NewService(memory.NewChannelStore())
|
||||
|
|
@ -2716,6 +2761,61 @@ func TestChannelUsernameAndSignatures(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestUpdateUsernameForcesPreHistoryVisible(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
const ownerID int64 = 1001
|
||||
service := NewService(memory.NewChannelStore())
|
||||
created, err := service.CreateMegagroupFromCreateChat(ctx, ownerID, domain.CreateChannelRequest{
|
||||
Title: "Private First",
|
||||
MemberUserIDs: []int64{1002},
|
||||
Date: 10,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateMegagroupFromCreateChat: %v", err)
|
||||
}
|
||||
|
||||
hidden, err := service.SetPreHistoryHidden(ctx, ownerID, created.Channel.ID, true)
|
||||
if err != nil {
|
||||
t.Fatalf("SetPreHistoryHidden: %v", err)
|
||||
}
|
||||
if !hidden.PreHistoryHidden {
|
||||
t.Fatalf("hidden channel = %+v, want pre-history hidden", hidden)
|
||||
}
|
||||
|
||||
// Assigning a public username must force pre-history back to visible.
|
||||
public, err := service.UpdateUsername(ctx, ownerID, domain.UpdateChannelUsernameRequest{
|
||||
ChannelID: created.Channel.ID,
|
||||
Username: "private_first_pub",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateUsername: %v", err)
|
||||
}
|
||||
if public.PreHistoryHidden {
|
||||
t.Fatalf("public channel = %+v, want pre-history visible after publish", public)
|
||||
}
|
||||
|
||||
// Removing the username leaves the flag alone (still visible).
|
||||
private, err := service.UpdateUsername(ctx, ownerID, domain.UpdateChannelUsernameRequest{
|
||||
ChannelID: created.Channel.ID,
|
||||
Username: "",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateUsername clear: %v", err)
|
||||
}
|
||||
if private.PreHistoryHidden {
|
||||
t.Fatalf("re-privated channel = %+v, want pre-history still visible", private)
|
||||
}
|
||||
|
||||
// ...and the creator can hide it again once private.
|
||||
rehidden, err := service.SetPreHistoryHidden(ctx, ownerID, created.Channel.ID, true)
|
||||
if err != nil {
|
||||
t.Fatalf("SetPreHistoryHidden after re-privating: %v", err)
|
||||
}
|
||||
if !rehidden.PreHistoryHidden {
|
||||
t.Fatalf("re-hidden channel = %+v, want pre-history hidden again", rehidden)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListStoryPostableChannelsFiltersPostStoryRights(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
service := NewService(memory.NewChannelStore())
|
||||
|
|
|
|||
|
|
@ -356,6 +356,163 @@ func apiMediaUsesCaption(media map[string]any) bool {
|
|||
return false
|
||||
}
|
||||
|
||||
// apiChatFull projects a getChat result. The bot-api chat-id encoding is applied
|
||||
// here: users keep their positive id, channels/supergroups become
|
||||
// -1000000000000 - channelID.
|
||||
func apiChatFull(chat domain.BotAPIChat) map[string]any {
|
||||
id := chat.Peer.ID
|
||||
if chat.Peer.Type == domain.PeerTypeChannel {
|
||||
id = -1000000000000 - chat.Peer.ID
|
||||
}
|
||||
out := map[string]any{"id": id, "type": chat.Type}
|
||||
if chat.Title != "" {
|
||||
out["title"] = chat.Title
|
||||
}
|
||||
if chat.Username != "" {
|
||||
out["username"] = chat.Username
|
||||
}
|
||||
if chat.FirstName != "" {
|
||||
out["first_name"] = chat.FirstName
|
||||
}
|
||||
if chat.LastName != "" {
|
||||
out["last_name"] = chat.LastName
|
||||
}
|
||||
if chat.Description != "" {
|
||||
out["description"] = chat.Description
|
||||
}
|
||||
if chat.IsForum {
|
||||
out["is_forum"] = true
|
||||
}
|
||||
if chat.Verified {
|
||||
out["is_verified"] = true
|
||||
}
|
||||
if chat.Scam {
|
||||
out["is_scam"] = true
|
||||
}
|
||||
if chat.Fake {
|
||||
out["is_fake"] = true
|
||||
}
|
||||
if chat.SlowModeDelay > 0 {
|
||||
out["slow_mode_delay"] = chat.SlowModeDelay
|
||||
}
|
||||
if chat.LinkedChatID != 0 {
|
||||
out["linked_chat_id"] = -1000000000000 - chat.LinkedChatID
|
||||
}
|
||||
if chat.Permissions != nil {
|
||||
out["permissions"] = apiChatPermissions(*chat.Permissions)
|
||||
}
|
||||
if chat.PinnedMessage != nil {
|
||||
out["pinned_message"] = apiMessage(*chat.PinnedMessage, chat.PinnedMessageUsers)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// apiChatPermissions projects a channel's default restrictions as a Bot API
|
||||
// ChatPermissions object (a right is granted when the matching restriction is
|
||||
// off).
|
||||
func apiChatPermissions(b domain.ChannelBannedRights) map[string]any {
|
||||
text := !b.SendMessages && !b.SendPlain
|
||||
return map[string]any{
|
||||
"can_send_messages": text,
|
||||
"can_send_audios": !b.SendMedia && !b.SendAudios,
|
||||
"can_send_documents": !b.SendMedia && !b.SendDocs,
|
||||
"can_send_photos": !b.SendMedia && !b.SendPhotos,
|
||||
"can_send_videos": !b.SendMedia && !b.SendVideos,
|
||||
"can_send_video_notes": !b.SendMedia && !b.SendRoundvideos,
|
||||
"can_send_voice_notes": !b.SendMedia && !b.SendVoices,
|
||||
"can_send_polls": !b.SendPolls,
|
||||
"can_send_other_messages": !b.SendStickers && !b.SendGifs && !b.SendGames && !b.SendInline,
|
||||
"can_add_web_page_previews": !b.EmbedLinks,
|
||||
"can_change_info": !b.ChangeInfo,
|
||||
"can_invite_users": !b.InviteUsers,
|
||||
"can_pin_messages": !b.PinMessages,
|
||||
"can_manage_topics": !b.ManageTopics,
|
||||
}
|
||||
}
|
||||
|
||||
// apiChatMember projects a resolved member as a Bot API ChatMember object.
|
||||
func apiChatMember(m domain.BotAPIChatMember) map[string]any {
|
||||
out := map[string]any{
|
||||
"status": botAPIMemberStatus(m.Member),
|
||||
"user": apiUser(userOrPlaceholder(m.User, m.Member.UserID)),
|
||||
}
|
||||
switch out["status"] {
|
||||
case "creator":
|
||||
if m.Member.AdminRights.Anonymous {
|
||||
out["is_anonymous"] = true
|
||||
}
|
||||
if m.Member.Rank != "" {
|
||||
out["custom_title"] = m.Member.Rank
|
||||
}
|
||||
case "administrator":
|
||||
a := m.Member.AdminRights
|
||||
out["can_be_edited"] = false
|
||||
out["is_anonymous"] = a.Anonymous
|
||||
out["can_manage_chat"] = a.ManageChat
|
||||
out["can_delete_messages"] = a.DeleteMessages
|
||||
out["can_manage_video_chats"] = a.ManageCall
|
||||
out["can_restrict_members"] = a.BanUsers
|
||||
out["can_promote_members"] = a.AddAdmins
|
||||
out["can_change_info"] = a.ChangeInfo
|
||||
out["can_invite_users"] = a.InviteUsers
|
||||
out["can_post_messages"] = a.PostMessages
|
||||
out["can_edit_messages"] = a.EditMessages
|
||||
out["can_pin_messages"] = a.PinMessages
|
||||
out["can_manage_topics"] = a.ManageTopics
|
||||
out["can_post_stories"] = a.PostStories
|
||||
out["can_edit_stories"] = a.EditStories
|
||||
out["can_delete_stories"] = a.DeleteStories
|
||||
if m.Member.Rank != "" {
|
||||
out["custom_title"] = m.Member.Rank
|
||||
}
|
||||
case "restricted":
|
||||
b := m.Member.BannedRights
|
||||
out["is_member"] = m.Member.Status == domain.ChannelMemberActive
|
||||
for k, v := range apiChatPermissions(b) {
|
||||
out[k] = v
|
||||
}
|
||||
if b.UntilDate > 0 {
|
||||
out["until_date"] = b.UntilDate
|
||||
}
|
||||
case "kicked":
|
||||
if m.Member.BannedRights.UntilDate > 0 {
|
||||
out["until_date"] = m.Member.BannedRights.UntilDate
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func userOrPlaceholder(u domain.User, id int64) domain.User {
|
||||
if u.ID != 0 {
|
||||
return u
|
||||
}
|
||||
return domain.User{ID: id}
|
||||
}
|
||||
|
||||
func botAPIMemberStatus(m domain.ChannelMember) string {
|
||||
switch {
|
||||
case m.Role == domain.ChannelRoleCreator:
|
||||
return "creator"
|
||||
case m.Status == domain.ChannelMemberKicked, m.Status == domain.ChannelMemberBanned, m.BannedRights.ViewMessages:
|
||||
return "kicked"
|
||||
case m.Role == domain.ChannelRoleAdmin:
|
||||
return "administrator"
|
||||
case m.Status == domain.ChannelMemberLeft:
|
||||
return "left"
|
||||
case botAPIMemberRestricted(m.BannedRights):
|
||||
return "restricted"
|
||||
default:
|
||||
return "member"
|
||||
}
|
||||
}
|
||||
|
||||
func botAPIMemberRestricted(b domain.ChannelBannedRights) bool {
|
||||
return b.SendMessages || b.SendMedia || b.SendStickers || b.SendGifs || b.SendGames ||
|
||||
b.SendInline || b.EmbedLinks || b.SendPolls || b.ChangeInfo || b.InviteUsers ||
|
||||
b.PinMessages || b.ManageTopics || b.SendPhotos || b.SendVideos || b.SendRoundvideos ||
|
||||
b.SendAudios || b.SendVoices || b.SendDocs || b.SendPlain || b.SendReactions
|
||||
}
|
||||
|
||||
func apiChat(peer domain.Peer, users map[int64]domain.User) map[string]any {
|
||||
switch peer.Type {
|
||||
case domain.PeerTypeUser:
|
||||
|
|
|
|||
|
|
@ -41,6 +41,9 @@ type WebAppService interface {
|
|||
|
||||
type GatewayService interface {
|
||||
BotAPISelf(ctx context.Context, botID int64) (domain.User, error)
|
||||
BotAPIChat(ctx context.Context, botID, chatID int64) (domain.BotAPIChat, error)
|
||||
BotAPIChatMemberCount(ctx context.Context, botID, chatID int64) (int, error)
|
||||
BotAPIChatMember(ctx context.Context, botID, chatID, userID int64) (domain.BotAPIChatMember, error)
|
||||
BotAPIUpdates(ctx context.Context, botID int64, offset int64) ([]domain.UpdateEvent, error)
|
||||
BotAPISendMessage(ctx context.Context, botID, chatID int64, text string, entities []domain.MessageEntity, replyMarkup *domain.MessageReplyMarkup, disableWebPagePreview, silent bool, replyToMessageID int) (domain.Message, error)
|
||||
BotAPISendRichMessage(ctx context.Context, botID, chatID int64, rich domain.BotAPIRichMessageInput, replyMarkup *domain.MessageReplyMarkup, silent, noForwards bool, replyToMessageID int, effectID int64) (domain.Message, error)
|
||||
|
|
@ -197,6 +200,12 @@ func (h *handler) handle(w http.ResponseWriter, r *http.Request) {
|
|||
switch strings.ToLower(method) {
|
||||
case "getme":
|
||||
h.getMe(w, r, botID)
|
||||
case "getchat":
|
||||
h.getChat(w, r, botID)
|
||||
case "getchatmembercount", "getchatmemberscount":
|
||||
h.getChatMemberCount(w, r, botID)
|
||||
case "getchatmember":
|
||||
h.getChatMember(w, r, botID)
|
||||
case "setmycommands":
|
||||
h.setMyCommands(w, r, botID)
|
||||
case "deletemycommands":
|
||||
|
|
@ -310,6 +319,81 @@ func (h *handler) getMe(w http.ResponseWriter, r *http.Request, botID int64) {
|
|||
writeAPIOK(w, apiUser(u))
|
||||
}
|
||||
|
||||
func (h *handler) getChat(w http.ResponseWriter, r *http.Request, botID int64) {
|
||||
if h.gateway == nil {
|
||||
writeAPIError(w, http.StatusNotImplemented, "METHOD_NOT_FOUND")
|
||||
return
|
||||
}
|
||||
values, err := requestValues(r)
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusBadRequest, "BAD_REQUEST")
|
||||
return
|
||||
}
|
||||
// Numeric chat_id only - no @username resolution.
|
||||
chatID, err := strconv.ParseInt(strings.TrimSpace(values["chat_id"]), 10, 64)
|
||||
if err != nil || chatID == 0 {
|
||||
writeAPIError(w, http.StatusBadRequest, "CHAT_ID_INVALID")
|
||||
return
|
||||
}
|
||||
chat, err := h.gateway.BotAPIChat(r.Context(), botID, chatID)
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusBadRequest, apiErrorDescription(err))
|
||||
return
|
||||
}
|
||||
writeAPIOK(w, apiChatFull(chat))
|
||||
}
|
||||
|
||||
func (h *handler) getChatMemberCount(w http.ResponseWriter, r *http.Request, botID int64) {
|
||||
if h.gateway == nil {
|
||||
writeAPIError(w, http.StatusNotImplemented, "METHOD_NOT_FOUND")
|
||||
return
|
||||
}
|
||||
values, err := requestValues(r)
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusBadRequest, "BAD_REQUEST")
|
||||
return
|
||||
}
|
||||
chatID, err := strconv.ParseInt(strings.TrimSpace(values["chat_id"]), 10, 64)
|
||||
if err != nil || chatID == 0 {
|
||||
writeAPIError(w, http.StatusBadRequest, "CHAT_ID_INVALID")
|
||||
return
|
||||
}
|
||||
count, err := h.gateway.BotAPIChatMemberCount(r.Context(), botID, chatID)
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusBadRequest, apiErrorDescription(err))
|
||||
return
|
||||
}
|
||||
writeAPIOK(w, count)
|
||||
}
|
||||
|
||||
func (h *handler) getChatMember(w http.ResponseWriter, r *http.Request, botID int64) {
|
||||
if h.gateway == nil {
|
||||
writeAPIError(w, http.StatusNotImplemented, "METHOD_NOT_FOUND")
|
||||
return
|
||||
}
|
||||
values, err := requestValues(r)
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusBadRequest, "BAD_REQUEST")
|
||||
return
|
||||
}
|
||||
chatID, err := strconv.ParseInt(strings.TrimSpace(values["chat_id"]), 10, 64)
|
||||
if err != nil || chatID == 0 {
|
||||
writeAPIError(w, http.StatusBadRequest, "CHAT_ID_INVALID")
|
||||
return
|
||||
}
|
||||
userID, err := strconv.ParseInt(strings.TrimSpace(values["user_id"]), 10, 64)
|
||||
if err != nil || userID <= 0 {
|
||||
writeAPIError(w, http.StatusBadRequest, "USER_ID_INVALID")
|
||||
return
|
||||
}
|
||||
member, err := h.gateway.BotAPIChatMember(r.Context(), botID, chatID, userID)
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusBadRequest, apiErrorDescription(err))
|
||||
return
|
||||
}
|
||||
writeAPIOK(w, apiChatMember(member))
|
||||
}
|
||||
|
||||
func (h *handler) getUpdates(w http.ResponseWriter, r *http.Request, botID int64) {
|
||||
if h.gateway == nil {
|
||||
writeAPIError(w, http.StatusNotImplemented, "METHOD_NOT_FOUND")
|
||||
|
|
@ -1457,6 +1541,7 @@ func apiErrorDescription(err error) string {
|
|||
"BUTTON_URL_INVALID",
|
||||
"BOT_INVALID",
|
||||
"CHAT_ID_INVALID",
|
||||
"CHAT_NOT_FOUND",
|
||||
"ENTITY_INVALID",
|
||||
"ENTITIES_TOO_LONG",
|
||||
"ENTITY_BOUNDS_INVALID",
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import (
|
|||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
|
|
@ -161,6 +162,111 @@ func TestGetMeUsesGateway(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestGetChatUsesGateway(t *testing.T) {
|
||||
bots := &fakeBotAPIBots{profile: domain.BotProfile{BotUserID: 1001, TokenSecret: "secret"}}
|
||||
gateway := &fakeBotAPIGateway{
|
||||
chat: domain.BotAPIChat{
|
||||
Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 42},
|
||||
Type: "supergroup",
|
||||
Title: "Test",
|
||||
Username: "test1",
|
||||
Description: "a test group",
|
||||
IsForum: true,
|
||||
},
|
||||
}
|
||||
h := (&handler{bots: bots, gateway: gateway}).routes()
|
||||
|
||||
rec := performBotAPIRequest(t, h, bots.profile, "getChat", `{"chat_id":-1000000000042}`)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if gateway.chatChatID != -1000000000042 {
|
||||
t.Fatalf("gateway chat_id = %d, want -1000000000042", gateway.chatChatID)
|
||||
}
|
||||
var resp struct {
|
||||
OK bool `json:"ok"`
|
||||
Result struct {
|
||||
ID int64 `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Title string `json:"title"`
|
||||
Username string `json:"username"`
|
||||
Description string `json:"description"`
|
||||
IsForum bool `json:"is_forum"`
|
||||
} `json:"result"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if !resp.OK || resp.Result.ID != -1000000000042 || resp.Result.Type != "supergroup" ||
|
||||
resp.Result.Username != "test1" || resp.Result.Description != "a test group" || !resp.Result.IsForum {
|
||||
t.Fatalf("response = %s", rec.Body.String())
|
||||
}
|
||||
|
||||
// A private chat the bot cannot see comes back as chat not found.
|
||||
gateway.chatErr = errors.New("CHAT_NOT_FOUND")
|
||||
rec = performBotAPIRequest(t, h, bots.profile, "getChat", `{"chat_id":-1000000000099}`)
|
||||
if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "CHAT_NOT_FOUND") {
|
||||
t.Fatalf("not-found response status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// @username is rejected before it reaches the gateway.
|
||||
rec = performBotAPIRequest(t, h, bots.profile, "getChat", `{"chat_id":"@test1"}`)
|
||||
if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "CHAT_ID_INVALID") {
|
||||
t.Fatalf("username response status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetChatMemberCountAndMember(t *testing.T) {
|
||||
bots := &fakeBotAPIBots{profile: domain.BotProfile{BotUserID: 1001, TokenSecret: "secret"}}
|
||||
gateway := &fakeBotAPIGateway{
|
||||
memberCount: 7,
|
||||
member: domain.BotAPIChatMember{
|
||||
User: domain.User{ID: 500, FirstName: "Ann"},
|
||||
Member: domain.ChannelMember{
|
||||
UserID: 500, Role: domain.ChannelRoleAdmin, Status: domain.ChannelMemberActive,
|
||||
AdminRights: domain.ChannelAdminRights{BanUsers: true, PinMessages: true},
|
||||
Rank: "mod",
|
||||
},
|
||||
},
|
||||
}
|
||||
h := (&handler{bots: bots, gateway: gateway}).routes()
|
||||
|
||||
rec := performBotAPIRequest(t, h, bots.profile, "getChatMemberCount", `{"chat_id":-1000000000042}`)
|
||||
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"result":7`) {
|
||||
t.Fatalf("getChatMemberCount status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
rec = performBotAPIRequest(t, h, bots.profile, "getChatMember", `{"chat_id":-1000000000042,"user_id":500}`)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("getChatMember status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var resp struct {
|
||||
OK bool `json:"ok"`
|
||||
Result struct {
|
||||
Status string `json:"status"`
|
||||
CustomTitle string `json:"custom_title"`
|
||||
CanRestrict bool `json:"can_restrict_members"`
|
||||
CanPromote bool `json:"can_promote_members"`
|
||||
User struct {
|
||||
ID int64 `json:"id"`
|
||||
} `json:"user"`
|
||||
} `json:"result"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if !resp.OK || resp.Result.Status != "administrator" || resp.Result.User.ID != 500 ||
|
||||
resp.Result.CustomTitle != "mod" || !resp.Result.CanRestrict || resp.Result.CanPromote {
|
||||
t.Fatalf("getChatMember result = %s", rec.Body.String())
|
||||
}
|
||||
|
||||
// user_id is required.
|
||||
rec = performBotAPIRequest(t, h, bots.profile, "getChatMember", `{"chat_id":-1000000000042}`)
|
||||
if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "USER_ID_INVALID") {
|
||||
t.Fatalf("missing user_id status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotCommandsPreserveEphemeralFlag(t *testing.T) {
|
||||
bots := &fakeBotAPIBots{profile: domain.BotProfile{BotUserID: 1001, TokenSecret: "secret"}}
|
||||
h := (&handler{bots: bots}).routes()
|
||||
|
|
@ -1442,6 +1548,13 @@ func (f *fakeWebAppService) SavePreparedInlineMessageFromBotAPI(_ context.Contex
|
|||
type fakeBotAPIGateway struct {
|
||||
self domain.User
|
||||
|
||||
chat domain.BotAPIChat
|
||||
chatErr error
|
||||
chatChatID int64
|
||||
memberCount int
|
||||
member domain.BotAPIChatMember
|
||||
memberErr error
|
||||
|
||||
updates []domain.UpdateEvent
|
||||
updateBotID int64
|
||||
updateOffset int64
|
||||
|
|
@ -1502,6 +1615,19 @@ func (f *fakeBotAPIGateway) BotAPISelf(context.Context, int64) (domain.User, err
|
|||
return f.self, nil
|
||||
}
|
||||
|
||||
func (f *fakeBotAPIGateway) BotAPIChat(_ context.Context, _ int64, chatID int64) (domain.BotAPIChat, error) {
|
||||
f.chatChatID = chatID
|
||||
return f.chat, f.chatErr
|
||||
}
|
||||
|
||||
func (f *fakeBotAPIGateway) BotAPIChatMemberCount(context.Context, int64, int64) (int, error) {
|
||||
return f.memberCount, f.memberErr
|
||||
}
|
||||
|
||||
func (f *fakeBotAPIGateway) BotAPIChatMember(context.Context, int64, int64, int64) (domain.BotAPIChatMember, error) {
|
||||
return f.member, f.memberErr
|
||||
}
|
||||
|
||||
func (f *fakeBotAPIGateway) BotAPIUpdates(_ context.Context, botID int64, offset int64) ([]domain.UpdateEvent, error) {
|
||||
f.updateBotID = botID
|
||||
f.updateOffset = offset
|
||||
|
|
|
|||
32
internal/domain/botapi_chat.go
Normal file
32
internal/domain/botapi_chat.go
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
package domain
|
||||
|
||||
// BotAPIChat is a peer resolved for the Bot API getChat method. The bot need
|
||||
// not be a member: a public channel or supergroup resolves (projected as a
|
||||
// preview), while a private chat the bot has no access to resolves to an error.
|
||||
type BotAPIChat struct {
|
||||
Peer Peer // domain peer; the Bot API chat-id encoding is applied by the projection
|
||||
Type string // "private" | "group" | "supergroup" | "channel"
|
||||
Title string
|
||||
Username string
|
||||
FirstName string
|
||||
LastName string
|
||||
Description string // channel/supergroup "about"
|
||||
IsForum bool
|
||||
Verified bool
|
||||
Scam bool
|
||||
Fake bool
|
||||
|
||||
// Channel/supergroup only, from the full view.
|
||||
SlowModeDelay int
|
||||
LinkedChatID int64 // domain channel id; the projection applies the Bot API encoding
|
||||
Permissions *ChannelBannedRights // default restrictions; nil for a user chat
|
||||
PinnedMessage *Message
|
||||
PinnedMessageUsers []User
|
||||
}
|
||||
|
||||
// BotAPIChatMember is a resolved chat member for the Bot API getChatMember
|
||||
// method.
|
||||
type BotAPIChatMember struct {
|
||||
User User
|
||||
Member ChannelMember
|
||||
}
|
||||
|
|
@ -714,6 +714,23 @@ type ChannelMessage struct {
|
|||
Deleted bool
|
||||
}
|
||||
|
||||
// ForumReplyTopicID resolves the topic a reply to target belongs to inside a
|
||||
// forum. Every forum message lives in exactly one topic, and a reply inherits
|
||||
// the target's topic - never the target's own id. Using target.ID is
|
||||
// discussion-thread logic (comment threads on a broadcast post) and does not
|
||||
// apply to forums: it manufactures a topic reference that no channel_forum_topics
|
||||
// row backs, which strict clients cannot place. A target with no recorded topic
|
||||
// is in General.
|
||||
func ForumReplyTopicID(target ChannelMessage) int {
|
||||
if target.Action != nil && target.Action.Type == ChannelActionTopicCreate {
|
||||
return target.ID // the target itself is a topic root
|
||||
}
|
||||
if target.ReplyTo != nil && target.ReplyTo.TopMessageID > 0 {
|
||||
return target.ReplyTo.TopMessageID
|
||||
}
|
||||
return ForumGeneralTopicID
|
||||
}
|
||||
|
||||
// ProjectChannelHistoryClearMessage returns the owner-local service-message
|
||||
// projection for one channel history boundary. Identity fields from the shared
|
||||
// source are retained when available, while all user payload, media, reply,
|
||||
|
|
|
|||
24
internal/domain/reserved_username.go
Normal file
24
internal/domain/reserved_username.go
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
package domain
|
||||
|
||||
import "time"
|
||||
|
||||
// MaxReservedUsernameReasonLength bounds the operator note on a reservation.
|
||||
const MaxReservedUsernameReasonLength = 512
|
||||
|
||||
// ReservedUsername is one entry in the operator username blocklist. A reserved
|
||||
// name cannot be taken as an editable username by any peer and cannot be minted
|
||||
// as a collectible.
|
||||
type ReservedUsername struct {
|
||||
Username string // display form (original case at reservation time)
|
||||
Reason string
|
||||
Actor string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// ReservedUsernameFilter pages the blocklist. Query matches a username prefix
|
||||
// (case-insensitive); an empty query lists everything.
|
||||
type ReservedUsernameFilter struct {
|
||||
Query string
|
||||
Limit int
|
||||
Offset int
|
||||
}
|
||||
|
|
@ -9,7 +9,7 @@ import (
|
|||
// officialUpdatesChannelMention is the public @username of the updates channel
|
||||
// linked from the welcome message. It is carried both in the template text and
|
||||
// in a MessageEntityMention so clients render it as a tappable link.
|
||||
const officialUpdatesChannelMention = "@zio"
|
||||
const officialUpdatesChannelMention = "@ziodotsh"
|
||||
|
||||
const officialWelcomeMessageTemplate = "👋 Welcome to OwpenGram!\n\nYou just signed in via %s.\n\nIf this wasn't you, revoke this session from \"Settings > Privacy and Security > Active sessions\" immediately.\n\nIf you haven't already, feel free to join " + officialUpdatesChannelMention + " for all the latest updates!"
|
||||
|
||||
|
|
|
|||
|
|
@ -33,6 +33,148 @@ func (r *Router) BotAPISelf(ctx context.Context, botID int64) (domain.User, erro
|
|||
return u, nil
|
||||
}
|
||||
|
||||
// BotAPIChat resolves a chat for the Bot API getChat method. chat_id is numeric
|
||||
// only (no @username). Public channels/supergroups resolve even when the bot is
|
||||
// not a member; a private chat the bot cannot access is CHAT_NOT_FOUND.
|
||||
func (r *Router) BotAPIChat(ctx context.Context, botID, chatID int64) (domain.BotAPIChat, error) {
|
||||
if r == nil || botID == 0 {
|
||||
return domain.BotAPIChat{}, errors.New("BOT_INVALID")
|
||||
}
|
||||
peer, ok := botAPIPeerFromChatID(chatID)
|
||||
if !ok {
|
||||
return domain.BotAPIChat{}, errors.New("CHAT_ID_INVALID")
|
||||
}
|
||||
switch peer.Type {
|
||||
case domain.PeerTypeUser:
|
||||
if r.deps.Users == nil {
|
||||
return domain.BotAPIChat{}, errors.New("CHAT_NOT_FOUND")
|
||||
}
|
||||
u, found, err := r.deps.Users.ByID(ctx, botID, peer.ID)
|
||||
if err != nil {
|
||||
return domain.BotAPIChat{}, err
|
||||
}
|
||||
if !found {
|
||||
return domain.BotAPIChat{}, errors.New("CHAT_NOT_FOUND")
|
||||
}
|
||||
return domain.BotAPIChat{
|
||||
Peer: peer,
|
||||
Type: "private",
|
||||
FirstName: u.FirstName,
|
||||
LastName: u.LastName,
|
||||
Username: u.Username,
|
||||
Verified: u.Verified,
|
||||
Scam: u.Scam,
|
||||
Fake: u.Fake,
|
||||
}, nil
|
||||
case domain.PeerTypeChannel:
|
||||
if r.deps.Channels == nil {
|
||||
return domain.BotAPIChat{}, errors.New("CHAT_NOT_FOUND")
|
||||
}
|
||||
view, err := r.deps.Channels.GetChannel(ctx, botID, peer.ID)
|
||||
if err != nil {
|
||||
return domain.BotAPIChat{}, botAPIChatErr(err)
|
||||
}
|
||||
ch := view.Channel
|
||||
typ := "supergroup"
|
||||
if ch.Broadcast && !ch.Megagroup {
|
||||
typ = "channel"
|
||||
}
|
||||
out := domain.BotAPIChat{
|
||||
Peer: peer,
|
||||
Type: typ,
|
||||
Title: ch.Title,
|
||||
Username: ch.Username,
|
||||
Description: ch.About,
|
||||
IsForum: ch.Forum,
|
||||
Verified: ch.Verified,
|
||||
Scam: ch.Scam,
|
||||
Fake: ch.Fake,
|
||||
SlowModeDelay: ch.SlowmodeSeconds,
|
||||
LinkedChatID: ch.LinkedChatID,
|
||||
}
|
||||
if typ == "supergroup" {
|
||||
perms := ch.DefaultBannedRights
|
||||
out.Permissions = &perms
|
||||
}
|
||||
if ch.PinnedMessageID > 0 {
|
||||
if hist, msgErr := r.deps.Channels.GetMessages(ctx, botID, peer.ID, []int{ch.PinnedMessageID}); msgErr == nil && len(hist.Messages) > 0 {
|
||||
pinned := botAPIMessageFromChannel(botID, hist.Messages[0])
|
||||
out.PinnedMessage = &pinned
|
||||
out.PinnedMessageUsers = hist.Users
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
return domain.BotAPIChat{}, errors.New("CHAT_ID_INVALID")
|
||||
}
|
||||
|
||||
// BotAPIChatMemberCount resolves getChatMemberCount. Channels/supergroups only.
|
||||
func (r *Router) BotAPIChatMemberCount(ctx context.Context, botID, chatID int64) (int, error) {
|
||||
if r == nil || botID == 0 {
|
||||
return 0, errors.New("BOT_INVALID")
|
||||
}
|
||||
peer, ok := botAPIPeerFromChatID(chatID)
|
||||
if !ok || peer.Type != domain.PeerTypeChannel {
|
||||
return 0, errors.New("CHAT_ID_INVALID")
|
||||
}
|
||||
if r.deps.Channels == nil {
|
||||
return 0, errors.New("CHAT_NOT_FOUND")
|
||||
}
|
||||
view, err := r.deps.Channels.ResolveChannel(ctx, botID, peer.ID)
|
||||
if err != nil {
|
||||
return 0, botAPIChatErr(err)
|
||||
}
|
||||
return view.Channel.ParticipantsCount, nil
|
||||
}
|
||||
|
||||
// BotAPIChatMember resolves getChatMember. Channels/supergroups only.
|
||||
func (r *Router) BotAPIChatMember(ctx context.Context, botID, chatID, userID int64) (domain.BotAPIChatMember, error) {
|
||||
if r == nil || botID == 0 {
|
||||
return domain.BotAPIChatMember{}, errors.New("BOT_INVALID")
|
||||
}
|
||||
if userID <= 0 {
|
||||
return domain.BotAPIChatMember{}, errors.New("USER_ID_INVALID")
|
||||
}
|
||||
peer, ok := botAPIPeerFromChatID(chatID)
|
||||
if !ok || peer.Type != domain.PeerTypeChannel {
|
||||
return domain.BotAPIChatMember{}, errors.New("CHAT_ID_INVALID")
|
||||
}
|
||||
if r.deps.Channels == nil {
|
||||
return domain.BotAPIChatMember{}, errors.New("CHAT_NOT_FOUND")
|
||||
}
|
||||
member, err := r.deps.Channels.GetParticipant(ctx, botID, peer.ID, userID)
|
||||
switch {
|
||||
case err == nil:
|
||||
case errors.Is(err, domain.ErrUserNotParticipant):
|
||||
// Bot API returns a "left" member for a user who is simply not in the
|
||||
// chat, as long as the chat itself is accessible.
|
||||
member = domain.ChannelMember{ChannelID: peer.ID, UserID: userID, Role: domain.ChannelRoleMember, Status: domain.ChannelMemberLeft}
|
||||
default:
|
||||
return domain.BotAPIChatMember{}, botAPIChatErr(err)
|
||||
}
|
||||
out := domain.BotAPIChatMember{Member: member}
|
||||
if r.deps.Users != nil {
|
||||
if u, found, uErr := r.deps.Users.ByID(ctx, botID, userID); uErr == nil && found {
|
||||
out.User = u
|
||||
}
|
||||
}
|
||||
if out.User.ID == 0 {
|
||||
out.User = domain.User{ID: userID}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func botAPIChatErr(err error) error {
|
||||
switch {
|
||||
case errors.Is(err, domain.ErrChannelInvalid),
|
||||
errors.Is(err, domain.ErrChannelPrivate),
|
||||
errors.Is(err, domain.ErrChannelUserBanned):
|
||||
return errors.New("CHAT_NOT_FOUND")
|
||||
default:
|
||||
return channelInvalidErr(err)
|
||||
}
|
||||
}
|
||||
|
||||
// BotAPIUpdates returns durable update_id based events projected for the HTTP
|
||||
// Bot API. New deployments use the dedicated Bot API queue; the legacy
|
||||
// user_update_events fallback is kept for tests that have not wired the queue.
|
||||
|
|
|
|||
|
|
@ -109,6 +109,60 @@ func TestMessagesGetFutureChatCreatorAfterLeaveAndCreatorLeaveTransfers(t *testi
|
|||
}
|
||||
}
|
||||
|
||||
func TestLeaveChannelInvalidatesStaleFullChannelProjection(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
userStore := memory.NewUserStore()
|
||||
owner, _ := userStore.Create(ctx, domain.User{AccessHash: 9401, Phone: "15550009401", FirstName: "Owner"})
|
||||
member, _ := userStore.Create(ctx, domain.User{AccessHash: 9402, Phone: "15550009402", FirstName: "Member"})
|
||||
channelStore := memory.NewChannelStore()
|
||||
channelService := appchannels.NewService(channelStore)
|
||||
r := New(Config{}, Deps{
|
||||
Users: appusers.NewService(userStore),
|
||||
Channels: channelService,
|
||||
}, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700009400, 0)})
|
||||
created, err := channelService.CreateChannel(ctx, owner.ID, domain.CreateChannelRequest{
|
||||
CreatorUserID: owner.ID,
|
||||
Title: "leave projection",
|
||||
Megagroup: true,
|
||||
Date: 1700009400,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create channel: %v", err)
|
||||
}
|
||||
if _, err := channelService.UpdateUsername(ctx, owner.ID, domain.UpdateChannelUsernameRequest{
|
||||
ChannelID: created.Channel.ID,
|
||||
Username: "leave_projection_pub",
|
||||
}); err != nil {
|
||||
t.Fatalf("publish channel: %v", err)
|
||||
}
|
||||
inputChannel := &tg.InputChannel{ChannelID: created.Channel.ID, AccessHash: created.Channel.AccessHash}
|
||||
|
||||
if _, err := r.onChannelsJoinChannel(WithUserID(ctx, member.ID), inputChannel); err != nil {
|
||||
t.Fatalf("member joins: %v", err)
|
||||
}
|
||||
// Warm the channels.getFullChannel projection cache while still a member.
|
||||
full, err := r.onChannelsGetFullChannel(WithUserID(ctx, member.ID), inputChannel)
|
||||
if err != nil {
|
||||
t.Fatalf("full channel while joined: %v", err)
|
||||
}
|
||||
if chat, ok := full.Chats[0].(*tg.Channel); !ok || chat.Left {
|
||||
t.Fatalf("joined full chat = %#v, want member (not left)", full.Chats[0])
|
||||
}
|
||||
|
||||
if _, err := r.onChannelsLeaveChannel(WithUserID(ctx, member.ID), inputChannel); err != nil {
|
||||
t.Fatalf("member leaves: %v", err)
|
||||
}
|
||||
|
||||
after, err := r.onChannelsGetFullChannel(WithUserID(ctx, member.ID), inputChannel)
|
||||
if err != nil {
|
||||
t.Fatalf("full channel after leave: %v", err)
|
||||
}
|
||||
chat, ok := after.Chats[0].(*tg.Channel)
|
||||
if !ok || !chat.Left {
|
||||
t.Fatalf("post-leave full chat = %#v, want left=true (stale projection served)", after.Chats[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessagesEditChatCreatorTransfersWithoutChannelPts(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
userStore := memory.NewUserStore()
|
||||
|
|
|
|||
|
|
@ -341,6 +341,7 @@ func (r *Router) onChannelsInviteToChannel(ctx context.Context, req *tg.Channels
|
|||
return nil, channelInviteErr(err)
|
||||
}
|
||||
r.invalidateChannelFullBotInfoCacheForChannel(res.Channel.ID)
|
||||
r.invalidateChannelMembershipProjection(res.Channel.ID, channelMemberUserIDs(res.Members))
|
||||
r.addOnlineChannelMemberships(res.Channel.ID, channelMemberUserIDs(res.Members)...)
|
||||
cache := newViewerPeerCache(r)
|
||||
updates := r.channelOperationUpdatesWithPeerCache(ctx, userID, res, cache)
|
||||
|
|
@ -379,6 +380,7 @@ func (r *Router) onChannelsJoinChannel(ctx context.Context, input tg.InputChanne
|
|||
return nil, channelInviteErr(err)
|
||||
}
|
||||
r.invalidateChannelFullBotInfoCacheForChannel(res.Channel.ID)
|
||||
r.invalidateChannelMembershipProjection(res.Channel.ID, channelMemberUserIDs(res.Members))
|
||||
r.addOnlineChannelMemberships(res.Channel.ID, channelMemberUserIDs(res.Members)...)
|
||||
updates := r.channelOperationUpdates(ctx, userID, res)
|
||||
r.pushChannelUpdates(ctx, userID, res.Channel.ID, res.Recipients, func(viewerUserID int64) *tg.Updates {
|
||||
|
|
@ -406,6 +408,11 @@ func (r *Router) onChannelsLeaveChannel(ctx context.Context, input tg.InputChann
|
|||
return nil, channelAdminErr(err)
|
||||
}
|
||||
r.invalidateChannelFullBotInfoCacheForChannel(res.Channel.ID)
|
||||
membershipChanged := channelMemberUserIDs(res.Members)
|
||||
if len(membershipChanged) == 0 {
|
||||
membershipChanged = []int64{userID}
|
||||
}
|
||||
r.invalidateChannelMembershipProjection(res.Channel.ID, membershipChanged)
|
||||
r.removeOnlineChannelMemberships(res.Channel.ID, userID)
|
||||
r.recordChannelStateForUser(ctx, userID, res.Channel.ID, true)
|
||||
updates := r.channelOperationUpdates(ctx, userID, res)
|
||||
|
|
@ -658,6 +665,7 @@ func (r *Router) onMessagesHideChatJoinRequest(ctx context.Context, req *tg.Mess
|
|||
return nil, channelInviteErr(err)
|
||||
}
|
||||
r.invalidateChannelFullBotInfoCacheForChannel(res.Channel.ID)
|
||||
r.invalidateChannelMembershipProjection(res.Channel.ID, channelMemberUserIDs(res.Members))
|
||||
r.addOnlineChannelMemberships(res.Channel.ID, channelMemberUserIDs(res.Members)...)
|
||||
updates := r.channelOperationUpdates(ctx, userID, res)
|
||||
r.appendPendingJoinRequestsUpdate(ctx, userID, updates, res.Channel)
|
||||
|
|
@ -696,6 +704,7 @@ func (r *Router) onMessagesHideAllChatJoinRequests(ctx context.Context, req *tg.
|
|||
return nil, channelInviteErr(err)
|
||||
}
|
||||
r.invalidateChannelFullBotInfoCacheForChannel(res.Channel.ID)
|
||||
r.invalidateChannelMembershipProjection(res.Channel.ID, channelMemberUserIDs(res.Members))
|
||||
r.addOnlineChannelMemberships(res.Channel.ID, channelMemberUserIDs(res.Members)...)
|
||||
updates := r.channelOperationUpdates(ctx, userID, res)
|
||||
r.appendPendingJoinRequestsUpdate(ctx, userID, updates, res.Channel)
|
||||
|
|
|
|||
|
|
@ -2,10 +2,6 @@ package rpc
|
|||
|
||||
import (
|
||||
"context"
|
||||
"github.com/iamxvbaba/td/clock"
|
||||
"github.com/iamxvbaba/td/proto"
|
||||
"github.com/iamxvbaba/td/tg"
|
||||
"go.uber.org/zap/zaptest"
|
||||
"strings"
|
||||
appchannels "telesrv/internal/app/channels"
|
||||
appupdates "telesrv/internal/app/updates"
|
||||
|
|
@ -13,6 +9,11 @@ import (
|
|||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store/memory"
|
||||
"testing"
|
||||
|
||||
"github.com/iamxvbaba/td/clock"
|
||||
"github.com/iamxvbaba/td/proto"
|
||||
"github.com/iamxvbaba/td/tg"
|
||||
"go.uber.org/zap/zaptest"
|
||||
)
|
||||
|
||||
func TestChannelRealtimeRecipientsPreferOnlineMembers(t *testing.T) {
|
||||
|
|
@ -662,7 +663,7 @@ func TestChannelSendMessageWithUnresolvableMentionSucceeds(t *testing.T) {
|
|||
t.Fatalf("create megagroup: %v", err)
|
||||
}
|
||||
peer := &tg.InputPeerChannel{ChannelID: created.Channel.ID, AccessHash: created.Channel.AccessHash}
|
||||
for i, text := range []string{"look at @zio", "hi @2cool and @_x"} {
|
||||
for i, text := range []string{"look at @ziodotsh", "hi @2cool and @_x"} {
|
||||
if _, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), &tg.MessagesSendMessageRequest{
|
||||
Peer: peer,
|
||||
Message: text,
|
||||
|
|
|
|||
120
internal/rpc/forum_reply_topic_rpc_test.go
Normal file
120
internal/rpc/forum_reply_topic_rpc_test.go
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
package rpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/iamxvbaba/td/clock"
|
||||
"github.com/iamxvbaba/td/tg"
|
||||
"go.uber.org/zap/zaptest"
|
||||
|
||||
appchannels "telesrv/internal/app/channels"
|
||||
appusers "telesrv/internal/app/users"
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
// A reply inside a forum must inherit the *target's* topic, never the target's
|
||||
// own message id. Regression: replying to a General message produced
|
||||
// reply_to_top_id = <that message's id>, a topic that no client can resolve, so
|
||||
// the reply vanished from every topic view and reply-jump said "doesn't exist".
|
||||
func TestForumReplyInheritsTargetTopic(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
userStore := memory.NewUserStore()
|
||||
owner, _ := userStore.Create(ctx, domain.User{AccessHash: 91, Phone: "15550009101", FirstName: "Owner"})
|
||||
channelStore := memory.NewChannelStore()
|
||||
r := New(Config{}, Deps{
|
||||
Users: appusers.NewService(userStore),
|
||||
Channels: appchannels.NewService(channelStore),
|
||||
}, zaptest.NewLogger(t), clock.System)
|
||||
ownerCtx := WithUserID(ctx, owner.ID)
|
||||
|
||||
created, err := r.onChannelsCreateChannel(ownerCtx, &tg.ChannelsCreateChannelRequest{Title: "Forum", Megagroup: true})
|
||||
if err != nil {
|
||||
t.Fatalf("create channel: %v", err)
|
||||
}
|
||||
channel := created.(*tg.Updates).Chats[0].(*tg.Channel)
|
||||
input := &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}
|
||||
peer := &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}
|
||||
if _, err := r.onChannelsToggleForum(ownerCtx, &tg.ChannelsToggleForumRequest{Channel: input, Enabled: true, Tabs: true}); err != nil {
|
||||
t.Fatalf("toggle forum: %v", err)
|
||||
}
|
||||
topicUpd, err := r.onMessagesCreateForumTopic(ownerCtx, &tg.MessagesCreateForumTopicRequest{
|
||||
Peer: peer, Title: "Test", IconColor: domain.DefaultForumTopicIconColor, RandomID: 9101001,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create topic: %v", err)
|
||||
}
|
||||
testTopicID := forumTopicRootMessageID(t, topicUpd, "Test")
|
||||
|
||||
send := func(text string, randomID int64, reply *tg.InputReplyToMessage) *tg.Message {
|
||||
req := &tg.MessagesSendMessageRequest{Peer: peer, Message: text, RandomID: randomID}
|
||||
if reply != nil {
|
||||
req.SetReplyTo(reply)
|
||||
}
|
||||
upd, err := r.onMessagesSendMessage(ownerCtx, req)
|
||||
if err != nil {
|
||||
t.Fatalf("send %q: %v", text, err)
|
||||
}
|
||||
for _, u := range upd.(*tg.Updates).Updates {
|
||||
if nm, ok := u.(*tg.UpdateNewChannelMessage); ok {
|
||||
if m, ok := nm.Message.(*tg.Message); ok && m.Message == text {
|
||||
return m
|
||||
}
|
||||
}
|
||||
}
|
||||
t.Fatalf("no new message for %q in %+v", text, upd)
|
||||
return nil
|
||||
}
|
||||
topID := func(m *tg.Message) int {
|
||||
h, ok := m.ReplyTo.(*tg.MessageReplyHeader)
|
||||
if !ok {
|
||||
t.Fatalf("message %d has reply header %T, want *MessageReplyHeader", m.ID, m.ReplyTo)
|
||||
}
|
||||
id, _ := h.GetReplyToTopID()
|
||||
if !h.ForumTopic {
|
||||
t.Fatalf("message %d reply header missing forum_topic flag: %+v", m.ID, h)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// A plain General message (no reply header).
|
||||
g1 := send("g1", 9101002, nil)
|
||||
|
||||
// Reply to it -> topic must be General (1), not g1.ID.
|
||||
r1 := send("r1", 9101003, &tg.InputReplyToMessage{ReplyToMsgID: g1.ID})
|
||||
if got := topID(r1); got != domain.ForumGeneralTopicID {
|
||||
t.Fatalf("reply to a General message: reply_to_top_id = %d, want %d (General), not the target id %d",
|
||||
got, domain.ForumGeneralTopicID, g1.ID)
|
||||
}
|
||||
|
||||
// Reply again, this time the client also passes top_msg_id: 1 (General).
|
||||
// Previously this was rejected because General has no channel_forum_topics row.
|
||||
replyWithTop := &tg.InputReplyToMessage{ReplyToMsgID: g1.ID}
|
||||
replyWithTop.SetTopMsgID(domain.ForumGeneralTopicID)
|
||||
r2 := send("r2", 9101004, replyWithTop)
|
||||
if got := topID(r2); got != domain.ForumGeneralTopicID {
|
||||
t.Fatalf("reply with top_msg_id=1: reply_to_top_id = %d, want %d", got, domain.ForumGeneralTopicID)
|
||||
}
|
||||
|
||||
// Post directly into the "Test" topic, then reply to a plain message there.
|
||||
tInTopic := &tg.InputReplyToMessage{ReplyToMsgID: 0}
|
||||
tInTopic.SetTopMsgID(testTopicID)
|
||||
m1 := send("t1", 9101005, tInTopic)
|
||||
if got := topID(m1); got != testTopicID {
|
||||
t.Fatalf("message in Test topic: reply_to_top_id = %d, want %d", got, testTopicID)
|
||||
}
|
||||
rt := send("rt", 9101006, &tg.InputReplyToMessage{ReplyToMsgID: m1.ID})
|
||||
if got := topID(rt); got != testTopicID {
|
||||
t.Fatalf("reply inside Test topic: reply_to_top_id = %d, want %d (topic), not %d", got, testTopicID, m1.ID)
|
||||
}
|
||||
|
||||
// Replying to a General message while claiming a mismatched topic is rejected.
|
||||
bad := &tg.InputReplyToMessage{ReplyToMsgID: g1.ID}
|
||||
bad.SetTopMsgID(testTopicID)
|
||||
req := &tg.MessagesSendMessageRequest{Peer: peer, Message: "bad", RandomID: 9101007}
|
||||
req.SetReplyTo(bad)
|
||||
if _, err := r.onMessagesSendMessage(ownerCtx, req); err == nil {
|
||||
t.Fatal("reply with a topic id that doesn't match the target's topic was accepted")
|
||||
}
|
||||
}
|
||||
133
internal/rpc/forum_topics_preview_rpc_test.go
Normal file
133
internal/rpc/forum_topics_preview_rpc_test.go
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
package rpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/iamxvbaba/td/clock"
|
||||
"github.com/iamxvbaba/td/tg"
|
||||
"go.uber.org/zap/zaptest"
|
||||
|
||||
appchannels "telesrv/internal/app/channels"
|
||||
appusers "telesrv/internal/app/users"
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
// A public forum's topic list is browsable before joining, like its history.
|
||||
// Regression: getForumTopics used the member-only access path and returned
|
||||
// CHANNEL_PRIVATE / an empty list to non-members, so the topic list (and even
|
||||
// General) was invisible until they joined.
|
||||
func TestGetForumTopicsVisibleToPublicNonMember(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
userStore := memory.NewUserStore()
|
||||
owner, _ := userStore.Create(ctx, domain.User{AccessHash: 81, Phone: "15550008101", FirstName: "Owner"})
|
||||
outsider, _ := userStore.Create(ctx, domain.User{AccessHash: 82, Phone: "15550008102", FirstName: "Outsider"})
|
||||
channelStore := memory.NewChannelStore()
|
||||
channelSvc := appchannels.NewService(channelStore)
|
||||
r := New(Config{}, Deps{
|
||||
Users: appusers.NewService(userStore),
|
||||
Channels: channelSvc,
|
||||
}, zaptest.NewLogger(t), clock.System)
|
||||
|
||||
ownerCtx := WithUserID(ctx, owner.ID)
|
||||
outsiderCtx := WithUserID(ctx, outsider.ID)
|
||||
|
||||
created, err := r.onChannelsCreateChannel(ownerCtx, &tg.ChannelsCreateChannelRequest{Title: "Public Forum", Megagroup: true})
|
||||
if err != nil {
|
||||
t.Fatalf("create channel: %v", err)
|
||||
}
|
||||
channel := created.(*tg.Updates).Chats[0].(*tg.Channel)
|
||||
input := &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}
|
||||
forumPeer := &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}
|
||||
|
||||
if _, err := r.onChannelsToggleForum(ownerCtx, &tg.ChannelsToggleForumRequest{Channel: input, Enabled: true, Tabs: true}); err != nil {
|
||||
t.Fatalf("toggle forum: %v", err)
|
||||
}
|
||||
if _, err := channelSvc.UpdateUsername(ctx, owner.ID, domain.UpdateChannelUsernameRequest{
|
||||
ChannelID: channel.ID,
|
||||
Username: "publicforum",
|
||||
}); err != nil {
|
||||
t.Fatalf("set channel username: %v", err)
|
||||
}
|
||||
if _, err := r.onMessagesCreateForumTopic(ownerCtx, &tg.MessagesCreateForumTopicRequest{
|
||||
Peer: forumPeer,
|
||||
Title: "Test",
|
||||
IconColor: domain.DefaultForumTopicIconColor,
|
||||
RandomID: 8101001,
|
||||
}); err != nil {
|
||||
t.Fatalf("create forum topic: %v", err)
|
||||
}
|
||||
|
||||
res, err := r.onMessagesGetForumTopics(outsiderCtx, &tg.MessagesGetForumTopicsRequest{
|
||||
Peer: forumPeer,
|
||||
Limit: 100,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("getForumTopics as non-member: %v", err)
|
||||
}
|
||||
titles := map[string]bool{}
|
||||
testTopicID := 0
|
||||
for _, tc := range res.Topics {
|
||||
switch topic := tc.(type) {
|
||||
case *tg.ForumTopic:
|
||||
titles[topic.Title] = true
|
||||
if topic.Title == "Test" {
|
||||
testTopicID = topic.ID
|
||||
}
|
||||
case *tg.ForumTopicDeleted:
|
||||
}
|
||||
}
|
||||
if !titles["General"] {
|
||||
t.Fatalf("non-member did not see the General topic: %+v", res.Topics)
|
||||
}
|
||||
if !titles["Test"] {
|
||||
t.Fatalf("non-member did not see the Test topic: %+v", res.Topics)
|
||||
}
|
||||
|
||||
// A non-member can also read the replies inside a topic (preview), the same
|
||||
// way ListChannelHistory lets them preview a public group's flat history.
|
||||
if testTopicID == 0 {
|
||||
t.Fatal("no Test topic id to open")
|
||||
}
|
||||
if _, err := r.onMessagesGetReplies(outsiderCtx, &tg.MessagesGetRepliesRequest{
|
||||
Peer: forumPeer,
|
||||
MsgID: testTopicID,
|
||||
Limit: 20,
|
||||
}); err != nil {
|
||||
t.Fatalf("getReplies as non-member of a public forum: %v", err)
|
||||
}
|
||||
|
||||
// The forum's own channel must come back with left=true so the client still
|
||||
// offers a Join button instead of treating the forum as already joined.
|
||||
var forumChat *tg.Channel
|
||||
for _, c := range res.Chats {
|
||||
if ch, ok := c.(*tg.Channel); ok && ch.ID == channel.ID {
|
||||
forumChat = ch
|
||||
}
|
||||
}
|
||||
if forumChat == nil {
|
||||
t.Fatalf("forum channel missing from getForumTopics chats: %+v", res.Chats)
|
||||
}
|
||||
if !forumChat.Left {
|
||||
t.Fatalf("non-member forum chat = %#v, want left=true", forumChat)
|
||||
}
|
||||
|
||||
// A private forum still refuses a non-member.
|
||||
priv, err := r.onChannelsCreateChannel(ownerCtx, &tg.ChannelsCreateChannelRequest{Title: "Private Forum", Megagroup: true})
|
||||
if err != nil {
|
||||
t.Fatalf("create private channel: %v", err)
|
||||
}
|
||||
privCh := priv.(*tg.Updates).Chats[0].(*tg.Channel)
|
||||
privInput := &tg.InputChannel{ChannelID: privCh.ID, AccessHash: privCh.AccessHash}
|
||||
privPeer := &tg.InputPeerChannel{ChannelID: privCh.ID, AccessHash: privCh.AccessHash}
|
||||
if _, err := r.onChannelsToggleForum(ownerCtx, &tg.ChannelsToggleForumRequest{Channel: privInput, Enabled: true, Tabs: true}); err != nil {
|
||||
t.Fatalf("toggle private forum: %v", err)
|
||||
}
|
||||
if _, err := r.onMessagesGetForumTopics(outsiderCtx, &tg.MessagesGetForumTopicsRequest{Peer: privPeer, Limit: 100}); err == nil {
|
||||
t.Fatal("non-member read a private forum's topic list")
|
||||
}
|
||||
if _, err := r.onMessagesGetReplies(outsiderCtx, &tg.MessagesGetRepliesRequest{Peer: privPeer, MsgID: 1, Limit: 20}); err == nil {
|
||||
t.Fatal("non-member read a private forum topic's replies")
|
||||
}
|
||||
}
|
||||
|
|
@ -474,12 +474,37 @@ func (r *Router) forumTopicsResponse(ctx context.Context, userID int64, view dom
|
|||
Count: count,
|
||||
Topics: topics,
|
||||
Messages: messages,
|
||||
Chats: tgChannels(userID, channels),
|
||||
Chats: r.forumTopicsChats(userID, view, channels),
|
||||
Users: r.tgUsersForIDs(ctx, userID, userIDs),
|
||||
Pts: view.Channel.Pts,
|
||||
})
|
||||
}
|
||||
|
||||
// forumTopicsChats projects the forum's own channel with the viewer's member
|
||||
// state (so a non-member preview carries left=true and the client still shows a
|
||||
// Join button) and every other referenced channel as a min chat. Rendering the
|
||||
// primary as a bare min chat lets a client that has no other object for the
|
||||
// channel treat the forum as already joined.
|
||||
func (r *Router) forumTopicsChats(userID int64, view domain.ChannelView, channels []domain.Channel) []tg.ChatClass {
|
||||
if view.Channel.ID == 0 {
|
||||
return tgChannels(userID, channels)
|
||||
}
|
||||
chats := make([]tg.ChatClass, 0, len(channels))
|
||||
chats = append(chats, tgChannelChatForView(userID, view))
|
||||
seen := map[int64]struct{}{view.Channel.ID: {}}
|
||||
for _, extra := range channels {
|
||||
if extra.ID == 0 {
|
||||
continue
|
||||
}
|
||||
if _, dup := seen[extra.ID]; dup {
|
||||
continue
|
||||
}
|
||||
seen[extra.ID] = struct{}{}
|
||||
chats = append(chats, tgChannelChatMin(userID, extra))
|
||||
}
|
||||
return chats
|
||||
}
|
||||
|
||||
func tgForumGeneralTopic(viewerUserID int64, view domain.ChannelView, topic domain.ChannelForumTopic) *tg.ForumTopic {
|
||||
return &tg.ForumTopic{
|
||||
My: view.Channel.CreatorUserID == viewerUserID && viewerUserID != 0,
|
||||
|
|
|
|||
|
|
@ -290,6 +290,24 @@ func (r *Router) invalidateRPCProjectionForPeer(ownerUserID int64, peer domain.P
|
|||
}
|
||||
}
|
||||
|
||||
// invalidateChannelMembershipProjection drops the cached channels.getFullChannel
|
||||
// projection for each user whose membership in channelID just changed (join,
|
||||
// leave, invite, request approval). Without it a client that polls
|
||||
// channels.getFullChannel right after channels.leaveChannel keeps getting a
|
||||
// projection that still shows it as an active member (left=false) until the
|
||||
// entry's TTL lapses, so it keeps an open compose box even though sends are
|
||||
// already rejected with CHANNEL_PRIVATE.
|
||||
func (r *Router) invalidateChannelMembershipProjection(channelID int64, userIDs []int64) {
|
||||
if r.channelFullProjectionCache == nil || channelID == 0 {
|
||||
return
|
||||
}
|
||||
for _, userID := range userIDs {
|
||||
if userID != 0 {
|
||||
r.channelFullProjectionCache.DeletePair(userID, channelID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Router) invalidateRPCProjectionForChannel(channelID int64) {
|
||||
if r.channelFullProjectionCache != nil {
|
||||
r.channelFullProjectionCache.DeleteChannel(channelID)
|
||||
|
|
|
|||
|
|
@ -540,18 +540,14 @@ func (s *ChannelStore) resolveChannelReplyLocked(req domain.SendChannelMessageRe
|
|||
if req.ReplyTo.TopMessageID <= 0 || !channel.Forum {
|
||||
return nil, domain.ErrReplyMessageIDInvalid
|
||||
}
|
||||
topic, ok := s.topics[req.ChannelID][req.ReplyTo.TopMessageID]
|
||||
if !ok || topic.Hidden {
|
||||
return nil, domain.ErrReplyMessageIDInvalid
|
||||
}
|
||||
if topic.Closed && !canManageForumTopic(channel, member, topic, req.UserID, selfBoostsApplied) {
|
||||
return nil, domain.ErrChannelWriteForbidden
|
||||
}
|
||||
reply := cloneMessageReply(req.ReplyTo)
|
||||
reply.MessageID = 0
|
||||
reply.Peer = channelPeer
|
||||
reply.TopMessageID = topic.TopicID
|
||||
reply.ForumTopic = true
|
||||
if err := s.validateForumReplyTopicLocked(channel, member, req.ReplyTo.TopMessageID, req.UserID, selfBoostsApplied); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reply.TopMessageID = req.ReplyTo.TopMessageID
|
||||
return reply, nil
|
||||
}
|
||||
target, ok := s.findMessageLocked(req.ChannelID, req.ReplyTo.MessageID)
|
||||
|
|
@ -561,6 +557,22 @@ func (s *ChannelStore) resolveChannelReplyLocked(req domain.SendChannelMessageRe
|
|||
reply := cloneMessageReply(req.ReplyTo)
|
||||
reply.MessageID = target.ID
|
||||
reply.Peer = channelPeer
|
||||
|
||||
if channel.Forum {
|
||||
// A forum reply belongs to the TARGET's topic, never the target's own id.
|
||||
topicID := domain.ForumReplyTopicID(target)
|
||||
if req.ReplyTo.TopMessageID > 0 && req.ReplyTo.TopMessageID != topicID {
|
||||
return nil, domain.ErrReplyMessageIDInvalid
|
||||
}
|
||||
if err := s.validateForumReplyTopicLocked(channel, member, topicID, req.UserID, selfBoostsApplied); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reply.TopMessageID = topicID
|
||||
reply.ForumTopic = true
|
||||
return reply, nil
|
||||
}
|
||||
|
||||
// Non-forum discussion thread: reply_to_top_id is the comment-thread root.
|
||||
reply.TopMessageID = target.ID
|
||||
if target.ReplyTo != nil && target.ReplyTo.TopMessageID > 0 {
|
||||
reply.TopMessageID = target.ReplyTo.TopMessageID
|
||||
|
|
@ -568,17 +580,25 @@ func (s *ChannelStore) resolveChannelReplyLocked(req domain.SendChannelMessageRe
|
|||
if req.ReplyTo.TopMessageID > 0 && req.ReplyTo.TopMessageID != reply.TopMessageID {
|
||||
return nil, domain.ErrReplyMessageIDInvalid
|
||||
}
|
||||
if channel.Forum && reply.TopMessageID > 0 {
|
||||
if topic, ok := s.topics[req.ChannelID][reply.TopMessageID]; ok && !topic.Hidden {
|
||||
if topic.Closed && !canManageForumTopic(channel, member, topic, req.UserID, selfBoostsApplied) {
|
||||
return nil, domain.ErrChannelWriteForbidden
|
||||
}
|
||||
reply.ForumTopic = true
|
||||
}
|
||||
}
|
||||
return reply, nil
|
||||
}
|
||||
|
||||
// validateForumReplyTopicLocked mirrors the postgres store: General
|
||||
// (ForumGeneralTopicID) is a virtual topic with no row and is always valid.
|
||||
func (s *ChannelStore) validateForumReplyTopicLocked(channel domain.Channel, member domain.ChannelMember, topicID int, userID int64, selfBoostsApplied int) error {
|
||||
if topicID == domain.ForumGeneralTopicID {
|
||||
return nil
|
||||
}
|
||||
topic, ok := s.topics[channel.ID][topicID]
|
||||
if !ok || topic.Hidden {
|
||||
return domain.ErrReplyMessageIDInvalid
|
||||
}
|
||||
if topic.Closed && !canManageForumTopic(channel, member, topic, userID, selfBoostsApplied) {
|
||||
return domain.ErrChannelWriteForbidden
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func inactiveChannelDate(dialog domain.Dialog, channel domain.Channel, member domain.ChannelMember) int {
|
||||
if dialog.TopMessageDate > 0 {
|
||||
return dialog.TopMessageDate
|
||||
|
|
|
|||
|
|
@ -128,6 +128,9 @@ func (s *ChannelStore) CheckUsername(_ context.Context, userID, channelID int64,
|
|||
return false, err
|
||||
}
|
||||
usernameLower := strings.ToLower(strings.TrimSpace(strings.TrimPrefix(username, "@")))
|
||||
if s.usernameRegistry != nil && s.usernameRegistry.nameReserved(usernameLower) {
|
||||
return false, nil
|
||||
}
|
||||
for id, channel := range s.channels {
|
||||
if channel.Deleted || channel.Username == "" {
|
||||
continue
|
||||
|
|
@ -175,6 +178,13 @@ func (s *ChannelStore) UpdateUsername(ctx context.Context, req domain.UpdateChan
|
|||
}
|
||||
prevUsername := channel.Username
|
||||
channel.Username = username
|
||||
// A public group cannot keep pre-history hidden: assigning a username forces
|
||||
// "chat history for new members" back to visible (matches the official
|
||||
// server). Removing the username leaves the flag untouched.
|
||||
clearedPrehistory := username != "" && channel.PreHistoryHidden
|
||||
if clearedPrehistory {
|
||||
channel.PreHistoryHidden = false
|
||||
}
|
||||
s.channels[req.ChannelID] = channel
|
||||
s.appendChannelAdminLogLocked(domain.ChannelAdminLogEvent{
|
||||
ChannelID: req.ChannelID,
|
||||
|
|
@ -184,6 +194,16 @@ func (s *ChannelStore) UpdateUsername(ctx context.Context, req domain.UpdateChan
|
|||
PrevString: prevUsername,
|
||||
NewString: username,
|
||||
})
|
||||
if clearedPrehistory {
|
||||
s.appendChannelAdminLogLocked(domain.ChannelAdminLogEvent{
|
||||
ChannelID: req.ChannelID,
|
||||
UserID: req.UserID,
|
||||
Date: int(time.Now().Unix()),
|
||||
Type: domain.ChannelAdminLogTogglePreHistoryHidden,
|
||||
PrevBool: true,
|
||||
NewBool: false,
|
||||
})
|
||||
}
|
||||
return channel, nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -178,7 +178,7 @@ func (s *ChannelStore) GeneralForumTopic(_ context.Context, viewerUserID, channe
|
|||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
channel, member, err := s.channelAndMemberLocked(viewerUserID, channelID)
|
||||
channel, member, _, err := s.channelForViewerLocked(viewerUserID, channelID)
|
||||
if err != nil {
|
||||
return domain.ChannelForumTopic{}, err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -420,7 +420,9 @@ func (s *ChannelStore) DeleteForumTopicHistory(_ context.Context, req domain.Del
|
|||
func (s *ChannelStore) ListForumTopics(_ context.Context, viewerUserID int64, filter domain.ChannelForumTopicFilter) (domain.ChannelForumTopicList, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
channel, member, err := s.channelAndMemberLocked(viewerUserID, filter.ChannelID)
|
||||
// channelForViewerLocked, not channelAndMemberLocked: a public forum's topic
|
||||
// list is browsable before joining, exactly like its message history.
|
||||
channel, member, _, err := s.channelForViewerLocked(viewerUserID, filter.ChannelID)
|
||||
if err != nil {
|
||||
return domain.ChannelForumTopicList{}, err
|
||||
}
|
||||
|
|
@ -463,7 +465,7 @@ func (s *ChannelStore) ListForumTopics(_ context.Context, viewerUserID int64, fi
|
|||
func (s *ChannelStore) GetForumTopicsByID(_ context.Context, viewerUserID, channelID int64, ids []int) (domain.ChannelForumTopicList, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
channel, member, err := s.channelAndMemberLocked(viewerUserID, channelID)
|
||||
channel, member, _, err := s.channelForViewerLocked(viewerUserID, channelID)
|
||||
if err != nil {
|
||||
return domain.ChannelForumTopicList{}, err
|
||||
}
|
||||
|
|
@ -499,7 +501,9 @@ func (s *ChannelStore) GetForumTopicsByID(_ context.Context, viewerUserID, chann
|
|||
func (s *ChannelStore) ListChannelReplies(_ context.Context, viewerUserID int64, filter domain.ChannelRepliesFilter) (domain.ChannelHistory, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
source, member, err := s.channelAndMemberOrLinkedGuestLocked(viewerUserID, filter.ChannelID)
|
||||
// Viewer scope (not strict membership): non-members can preview topic
|
||||
// replies in a public channel/supergroup, matching ListChannelHistory.
|
||||
source, member, _, err := s.channelForViewerLocked(viewerUserID, filter.ChannelID)
|
||||
if err != nil {
|
||||
return domain.ChannelHistory{}, err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -55,6 +55,26 @@ type CollectibleUsernameStore struct {
|
|||
transfers map[int64][]domain.CollectibleUsernameTransfer
|
||||
// commands maps a provenance command key onto the asset it touched.
|
||||
commands map[string]int64
|
||||
// reserved, when set, is the operator blocklist consulted before a name is
|
||||
// assigned to an editable slot or minted, mirroring the PostgreSQL checks.
|
||||
reserved *ReservedUsernameStore
|
||||
}
|
||||
|
||||
// WithReservedUsernames wires the operator blocklist into the registry so a
|
||||
// reserved name is refused, matching PostgreSQL.
|
||||
func (s *CollectibleUsernameStore) WithReservedUsernames(reserved *ReservedUsernameStore) *CollectibleUsernameStore {
|
||||
s.reserved = reserved
|
||||
return s
|
||||
}
|
||||
|
||||
// nameReserved reports whether a name is on the operator blocklist. It touches
|
||||
// only s.reserved (its own lock), so it is safe from any context.
|
||||
func (s *CollectibleUsernameStore) nameReserved(usernameLower string) bool {
|
||||
if s == nil || s.reserved == nil {
|
||||
return false
|
||||
}
|
||||
r, _ := s.reserved.IsReserved(context.Background(), usernameLower)
|
||||
return r
|
||||
}
|
||||
|
||||
// collectibleRegistryRow is one peer_usernames row: the owning peer plus the
|
||||
|
|
@ -101,6 +121,9 @@ func (s *CollectibleUsernameStore) SetEditableUsername(_ context.Context, peer d
|
|||
return false, domain.ErrUsernameInvalid
|
||||
}
|
||||
key := strings.ToLower(username)
|
||||
if s.nameReserved(key) {
|
||||
return false, domain.ErrUsernameOccupied
|
||||
}
|
||||
if existing, ok := s.registry[key]; ok {
|
||||
if existing.peer == peer && existing.row.Editable {
|
||||
if existing.row.Username == username {
|
||||
|
|
@ -313,6 +336,9 @@ func (s *CollectibleUsernameStore) MintCollectibleUsername(_ context.Context, re
|
|||
if _, ok := s.registry[key]; ok {
|
||||
return domain.CollectibleUsername{}, false, domain.ErrUsernameOccupied
|
||||
}
|
||||
if s.nameReserved(key) {
|
||||
return domain.CollectibleUsername{}, false, domain.ErrUsernameOccupied
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
purchaseDate := req.PurchaseDate
|
||||
if purchaseDate.IsZero() {
|
||||
|
|
|
|||
100
internal/store/memory/reserved_username.go
Normal file
100
internal/store/memory/reserved_username.go
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// ReservedUsernameStore is the in-memory operator username blocklist.
|
||||
type ReservedUsernameStore struct {
|
||||
mu sync.Mutex
|
||||
entries map[string]domain.ReservedUsername // keyed by username_lower
|
||||
}
|
||||
|
||||
// NewReservedUsernameStore creates an empty blocklist.
|
||||
func NewReservedUsernameStore() *ReservedUsernameStore {
|
||||
return &ReservedUsernameStore{entries: make(map[string]domain.ReservedUsername)}
|
||||
}
|
||||
|
||||
func (s *ReservedUsernameStore) IsReserved(_ context.Context, usernameLower string) (bool, error) {
|
||||
if s == nil {
|
||||
return false, nil
|
||||
}
|
||||
usernameLower = strings.ToLower(strings.TrimSpace(usernameLower))
|
||||
if usernameLower == "" {
|
||||
return false, nil
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
_, ok := s.entries[usernameLower]
|
||||
return ok, nil
|
||||
}
|
||||
|
||||
func (s *ReservedUsernameStore) ReserveUsername(_ context.Context, username, reason, actor string) (bool, error) {
|
||||
username = strings.TrimSpace(username)
|
||||
lower := strings.ToLower(username)
|
||||
if lower == "" {
|
||||
return false, domain.ErrUsernameInvalid
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if _, ok := s.entries[lower]; ok {
|
||||
return false, nil
|
||||
}
|
||||
s.entries[lower] = domain.ReservedUsername{Username: username, Reason: reason, Actor: actor, CreatedAt: time.Now().UTC()}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (s *ReservedUsernameStore) UnreserveUsername(_ context.Context, username string) (bool, error) {
|
||||
lower := strings.ToLower(strings.TrimSpace(username))
|
||||
if lower == "" {
|
||||
return false, domain.ErrUsernameInvalid
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if _, ok := s.entries[lower]; !ok {
|
||||
return false, nil
|
||||
}
|
||||
delete(s.entries, lower)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (s *ReservedUsernameStore) ReservedUsernames(_ context.Context, filter domain.ReservedUsernameFilter) ([]domain.ReservedUsername, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
q := strings.ToLower(strings.TrimSpace(filter.Query))
|
||||
out := make([]domain.ReservedUsername, 0, len(s.entries))
|
||||
for key, entry := range s.entries {
|
||||
if q != "" && !strings.HasPrefix(key, q) {
|
||||
continue
|
||||
}
|
||||
out = append(out, entry)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
if !out[i].CreatedAt.Equal(out[j].CreatedAt) {
|
||||
return out[i].CreatedAt.After(out[j].CreatedAt)
|
||||
}
|
||||
return strings.ToLower(out[i].Username) < strings.ToLower(out[j].Username)
|
||||
})
|
||||
limit := filter.Limit
|
||||
if limit <= 0 || limit > 500 {
|
||||
limit = 100
|
||||
}
|
||||
offset := filter.Offset
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
if offset >= len(out) {
|
||||
return []domain.ReservedUsername{}, nil
|
||||
}
|
||||
end := offset + limit
|
||||
if end > len(out) {
|
||||
end = len(out)
|
||||
}
|
||||
return out[offset:end], nil
|
||||
}
|
||||
37
internal/store/memory/reserved_username_test.go
Normal file
37
internal/store/memory/reserved_username_test.go
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func TestCheckUsernameReportsReservedAsTaken(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
reserved := NewReservedUsernameStore()
|
||||
if _, err := reserved.ReserveUsername(ctx, "support", "official", "ops"); err != nil {
|
||||
t.Fatalf("seed reserve: %v", err)
|
||||
}
|
||||
registry := NewCollectibleUsernameStore().WithReservedUsernames(reserved)
|
||||
|
||||
users := NewUserStore()
|
||||
users.AttachUsernameRegistry(registry)
|
||||
u, _ := users.Create(ctx, domain.User{AccessHash: 1, Phone: "15550001000", FirstName: "A"})
|
||||
if ok, err := users.CheckUsername(ctx, u.ID, "support"); err != nil || ok {
|
||||
t.Fatalf("CheckUsername(reserved) = %v, %v; want false, nil", ok, err)
|
||||
}
|
||||
if ok, err := users.CheckUsername(ctx, u.ID, "freename"); err != nil || !ok {
|
||||
t.Fatalf("CheckUsername(free) = %v, %v; want true, nil", ok, err)
|
||||
}
|
||||
|
||||
channels := NewChannelStore()
|
||||
channels.AttachUsernameRegistry(registry)
|
||||
created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{CreatorUserID: u.ID, Title: "C", Megagroup: true, Date: 1})
|
||||
if err != nil {
|
||||
t.Fatalf("create channel: %v", err)
|
||||
}
|
||||
if ok, err := channels.CheckUsername(ctx, u.ID, created.Channel.ID, "support"); err != nil || ok {
|
||||
t.Fatalf("channel CheckUsername(reserved) = %v, %v; want false, nil", ok, err)
|
||||
}
|
||||
}
|
||||
|
|
@ -158,6 +158,9 @@ func (s *UserStore) CheckUsername(_ context.Context, userID int64, username stri
|
|||
if username == "" {
|
||||
return true, nil
|
||||
}
|
||||
if s.usernameRegistry != nil && s.usernameRegistry.nameReserved(username) {
|
||||
return false, nil
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
for id, u := range s.byID {
|
||||
|
|
|
|||
|
|
@ -795,21 +795,14 @@ func (s *ChannelStore) resolveChannelReply(ctx context.Context, db sqlcgen.DBTX,
|
|||
if req.ReplyTo.TopMessageID <= 0 || !channel.Forum {
|
||||
return nil, domain.ErrReplyMessageIDInvalid
|
||||
}
|
||||
topic, err := s.getForumTopic(ctx, db, req.ChannelID, req.ReplyTo.TopMessageID)
|
||||
if err != nil {
|
||||
return nil, domain.ErrReplyMessageIDInvalid
|
||||
}
|
||||
if topic.Hidden {
|
||||
return nil, domain.ErrReplyMessageIDInvalid
|
||||
}
|
||||
if topic.Closed && !canManageForumTopic(channel, member, topic, req.UserID, selfBoostsApplied) {
|
||||
return nil, domain.ErrChannelWriteForbidden
|
||||
}
|
||||
reply := cloneMessageReply(req.ReplyTo)
|
||||
reply.MessageID = 0
|
||||
reply.Peer = channelPeer
|
||||
reply.TopMessageID = topic.TopicID
|
||||
reply.ForumTopic = true
|
||||
if err := s.validateForumReplyTopic(ctx, db, channel, member, req.ReplyTo.TopMessageID, req.UserID, selfBoostsApplied); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reply.TopMessageID = req.ReplyTo.TopMessageID
|
||||
return reply, nil
|
||||
}
|
||||
target, err := s.getChannelMessage(ctx, db, req.ChannelID, req.ReplyTo.MessageID)
|
||||
|
|
@ -825,6 +818,22 @@ func (s *ChannelStore) resolveChannelReply(ctx context.Context, db sqlcgen.DBTX,
|
|||
reply := cloneMessageReply(req.ReplyTo)
|
||||
reply.MessageID = target.ID
|
||||
reply.Peer = channelPeer
|
||||
|
||||
if channel.Forum {
|
||||
// A forum reply belongs to the TARGET's topic, never the target's own id.
|
||||
topicID := domain.ForumReplyTopicID(target)
|
||||
if req.ReplyTo.TopMessageID > 0 && req.ReplyTo.TopMessageID != topicID {
|
||||
return nil, domain.ErrReplyMessageIDInvalid
|
||||
}
|
||||
if err := s.validateForumReplyTopic(ctx, db, channel, member, topicID, req.UserID, selfBoostsApplied); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reply.TopMessageID = topicID
|
||||
reply.ForumTopic = true
|
||||
return reply, nil
|
||||
}
|
||||
|
||||
// Non-forum discussion thread: reply_to_top_id is the comment-thread root.
|
||||
reply.TopMessageID = target.ID
|
||||
if target.ReplyTo != nil && target.ReplyTo.TopMessageID > 0 {
|
||||
reply.TopMessageID = target.ReplyTo.TopMessageID
|
||||
|
|
@ -832,19 +841,29 @@ func (s *ChannelStore) resolveChannelReply(ctx context.Context, db sqlcgen.DBTX,
|
|||
if req.ReplyTo.TopMessageID > 0 && req.ReplyTo.TopMessageID != reply.TopMessageID {
|
||||
return nil, domain.ErrReplyMessageIDInvalid
|
||||
}
|
||||
if channel.Forum && reply.TopMessageID > 0 {
|
||||
if topic, err := s.getForumTopic(ctx, db, req.ChannelID, reply.TopMessageID); err == nil && !topic.Hidden {
|
||||
if topic.Closed && !canManageForumTopic(channel, member, topic, req.UserID, selfBoostsApplied) {
|
||||
return nil, domain.ErrChannelWriteForbidden
|
||||
}
|
||||
reply.ForumTopic = true
|
||||
} else if err != nil && !errors.Is(err, domain.ErrMessageIDInvalid) {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return reply, nil
|
||||
}
|
||||
|
||||
// validateForumReplyTopic checks that topicID is a topic the caller may post
|
||||
// into. General (ForumGeneralTopicID) is a virtual topic with no
|
||||
// channel_forum_topics row and is always valid.
|
||||
func (s *ChannelStore) validateForumReplyTopic(ctx context.Context, db sqlcgen.DBTX, channel domain.Channel, member domain.ChannelMember, topicID int, userID int64, selfBoostsApplied int) error {
|
||||
if topicID == domain.ForumGeneralTopicID {
|
||||
return nil
|
||||
}
|
||||
topic, err := s.getForumTopic(ctx, db, channel.ID, topicID)
|
||||
if err != nil {
|
||||
return domain.ErrReplyMessageIDInvalid
|
||||
}
|
||||
if topic.Hidden {
|
||||
return domain.ErrReplyMessageIDInvalid
|
||||
}
|
||||
if topic.Closed && !canManageForumTopic(channel, member, topic, userID, selfBoostsApplied) {
|
||||
return domain.ErrChannelWriteForbidden
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func visibleChannelTopAfter(ctx context.Context, db sqlcgen.DBTX, channelID int64, availableMinID int, fallbackDate int) (int, int, error) {
|
||||
var id, date int
|
||||
err := db.QueryRow(ctx, `
|
||||
|
|
|
|||
|
|
@ -103,6 +103,7 @@ func (s *ChannelStore) ImportInvite(ctx context.Context, req domain.ImportChanne
|
|||
return domain.CreateChannelResult{}, fmt.Errorf("commit import channel invite: %w", err)
|
||||
}
|
||||
committed = true
|
||||
s.invalidateChannelMembershipCaches(result.Channel.ID, req.UserID)
|
||||
recipients, _ := s.ListActiveChannelMemberIDs(ctx, req.UserID, result.Channel.ID, 0)
|
||||
result.Recipients = recipients
|
||||
return result, nil
|
||||
|
|
|
|||
|
|
@ -125,6 +125,7 @@ func (s *ChannelStore) InviteToChannel(ctx context.Context, channelID, inviterUs
|
|||
return domain.CreateChannelResult{}, fmt.Errorf("commit invite channel: %w", err)
|
||||
}
|
||||
committed = true
|
||||
s.invalidateChannelMembershipCaches(channelID, invitedIDs...)
|
||||
recipients, _ := s.ListActiveChannelMemberIDs(ctx, inviterUserID, channelID, 0)
|
||||
return domain.CreateChannelResult{Channel: channel, Members: members, Message: msg, Event: event, Recipients: recipients}, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -130,6 +130,7 @@ WHERE channel_id = $1 AND user_id = $2`, channelID, userID, member.ReadInboxMaxI
|
|||
return domain.CreateChannelResult{}, fmt.Errorf("commit join channel: %w", err)
|
||||
}
|
||||
committed = true
|
||||
s.invalidateChannelMembershipCaches(channelID, userID)
|
||||
recipients, _ := s.ListActiveChannelMemberIDs(ctx, userID, channelID, 0)
|
||||
return domain.CreateChannelResult{Channel: channel, Members: []domain.ChannelMember{member}, Message: msg, Event: event, Recipients: recipients}, nil
|
||||
}
|
||||
|
|
@ -259,6 +260,11 @@ WHERE id = $1`, channelID, channel.CreatorUserID, adminsDelta); err != nil {
|
|||
return domain.CreateChannelResult{}, fmt.Errorf("commit leave channel: %w", err)
|
||||
}
|
||||
committed = true
|
||||
leftUserIDs := make([]int64, 0, len(members))
|
||||
for _, m := range members {
|
||||
leftUserIDs = append(leftUserIDs, m.UserID)
|
||||
}
|
||||
s.invalidateChannelMembershipCaches(channelID, leftUserIDs...)
|
||||
recipients = append(recipients, userID)
|
||||
return domain.CreateChannelResult{Channel: channel, Members: members, Message: msg, Event: event, Recipients: recipients}, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -241,12 +241,29 @@ func (s *ChannelStore) UpdateUsername(ctx context.Context, req domain.UpdateChan
|
|||
if err := replacePeerUsernameTx(ctx, tx, peerUsernameTypeChannel, req.ChannelID, username, usernameLower); err != nil {
|
||||
return domain.Channel{}, err
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `UPDATE channels SET username = NULLIF($2,''), updated_at = now() WHERE id = $1`, req.ChannelID, username); err != nil {
|
||||
// A public group cannot keep pre-history hidden: assigning a username forces
|
||||
// "chat history for new members" back to visible (matches the official
|
||||
// server). Removing the username leaves the flag untouched, so the creator
|
||||
// can hide history again once the group is private.
|
||||
if _, err := tx.Exec(ctx, `UPDATE channels SET username = NULLIF($2,''), pre_history_hidden = (pre_history_hidden AND $2 = ''), updated_at = now() WHERE id = $1`, req.ChannelID, username); err != nil {
|
||||
return domain.Channel{}, fmt.Errorf("update channel username: %w", err)
|
||||
}
|
||||
if err := markUserChannelMemberIndexPublicTx(ctx, tx, req.ChannelID, username != ""); err != nil {
|
||||
return domain.Channel{}, err
|
||||
}
|
||||
if username != "" && channel.PreHistoryHidden {
|
||||
if err := s.insertChannelAdminLogTx(ctx, tx, domain.ChannelAdminLogEvent{
|
||||
ChannelID: req.ChannelID,
|
||||
UserID: req.UserID,
|
||||
Date: nowUnix(),
|
||||
Type: domain.ChannelAdminLogTogglePreHistoryHidden,
|
||||
PrevBool: true,
|
||||
NewBool: false,
|
||||
}); err != nil {
|
||||
return domain.Channel{}, err
|
||||
}
|
||||
channel.PreHistoryHidden = false
|
||||
}
|
||||
prevUsername := channel.Username
|
||||
channel.Username = username
|
||||
if err := s.insertChannelAdminLogTx(ctx, tx, domain.ChannelAdminLogEvent{
|
||||
|
|
|
|||
|
|
@ -95,6 +95,33 @@ func (s *ChannelStore) boostCacheActive(db sqlcgen.DBTX) bool {
|
|||
return s.boostCache != nil && db == s.db
|
||||
}
|
||||
|
||||
// invalidateChannelMembershipCaches drops the in-process reads that a membership
|
||||
// change (join, leave, invite, kick) makes stale for the given users. The
|
||||
// ReadModelChangeListener also clears these off the async NOTIFY, but callers
|
||||
// must not depend on that round-trip: a client that polls channels.getFullChannel
|
||||
// right after channels.leaveChannel would otherwise keep seeing itself as an
|
||||
// active member (and keep an open compose box) until the notify lands. Call it
|
||||
// post-commit.
|
||||
func (s *ChannelStore) invalidateChannelMembershipCaches(channelID int64, userIDs ...int64) {
|
||||
if channelID == 0 {
|
||||
return
|
||||
}
|
||||
if s.rowCache != nil {
|
||||
s.rowCache.delete(channelID)
|
||||
}
|
||||
for _, userID := range userIDs {
|
||||
if userID == 0 {
|
||||
continue
|
||||
}
|
||||
if s.memberCache != nil {
|
||||
s.memberCache.delete(channelID, userID)
|
||||
}
|
||||
if s.dialogCache != nil {
|
||||
s.dialogCache.delete(userID, channelID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// NewChannelStore 基于 pgx 连接池(或事务)创建 ChannelStore。
|
||||
func NewChannelStore(db sqlcgen.DBTX, opts ...ChannelStoreOption) *ChannelStore {
|
||||
s := &ChannelStore{db: db}
|
||||
|
|
|
|||
|
|
@ -198,7 +198,7 @@ func (s *ChannelStore) GeneralForumTopic(ctx context.Context, viewerUserID, chan
|
|||
if viewerUserID == 0 || channelID == 0 {
|
||||
return domain.ChannelForumTopic{}, domain.ErrChannelInvalid
|
||||
}
|
||||
channel, member, err := s.getChannelForMember(ctx, s.db, viewerUserID, channelID)
|
||||
channel, member, _, err := s.getChannelForViewer(ctx, s.db, viewerUserID, channelID)
|
||||
if err != nil {
|
||||
return domain.ChannelForumTopic{}, err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -470,7 +470,9 @@ WHERE channel_id = $1 AND topic_id = $2`, req.ChannelID, req.TopicID); err != ni
|
|||
}
|
||||
|
||||
func (s *ChannelStore) ListForumTopics(ctx context.Context, viewerUserID int64, filter domain.ChannelForumTopicFilter) (domain.ChannelForumTopicList, error) {
|
||||
channel, member, err := s.getChannelForMember(ctx, s.db, viewerUserID, filter.ChannelID)
|
||||
// getChannelForViewer, not getChannelForMember: a public forum's topic list is
|
||||
// browsable before joining, exactly like its message history.
|
||||
channel, member, _, err := s.getChannelForViewer(ctx, s.db, viewerUserID, filter.ChannelID)
|
||||
if err != nil {
|
||||
return domain.ChannelForumTopicList{}, err
|
||||
}
|
||||
|
|
@ -539,7 +541,7 @@ LIMIT $`+fmt.Sprint(len(args)), args...)
|
|||
}
|
||||
|
||||
func (s *ChannelStore) GetForumTopicsByID(ctx context.Context, viewerUserID, channelID int64, ids []int) (domain.ChannelForumTopicList, error) {
|
||||
channel, member, err := s.getChannelForMember(ctx, s.db, viewerUserID, channelID)
|
||||
channel, member, _, err := s.getChannelForViewer(ctx, s.db, viewerUserID, channelID)
|
||||
if err != nil {
|
||||
return domain.ChannelForumTopicList{}, err
|
||||
}
|
||||
|
|
@ -586,7 +588,9 @@ ORDER BY pinned DESC, pinned_order DESC, date DESC, topic_id DESC`, channelID, m
|
|||
}
|
||||
|
||||
func (s *ChannelStore) ListChannelReplies(ctx context.Context, viewerUserID int64, filter domain.ChannelRepliesFilter) (domain.ChannelHistory, error) {
|
||||
source, member, err := s.getChannelForMemberOrLinkedGuest(ctx, s.db, viewerUserID, filter.ChannelID)
|
||||
// Viewer口径(非严格 member):公开频道/超级群的非成员可预览话题回复,与
|
||||
// ListChannelHistory 一致。私有频道非成员仍是 ErrChannelPrivate。
|
||||
source, member, _, err := s.getChannelForViewer(ctx, s.db, viewerUserID, filter.ChannelID)
|
||||
if err != nil {
|
||||
return domain.ChannelHistory{}, err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -206,6 +206,11 @@ func (s *CollectibleUsernameStore) MintCollectibleUsername(ctx context.Context,
|
|||
} else if found {
|
||||
return domain.ErrUsernameOccupied
|
||||
}
|
||||
if reserved, err := usernameReservedTx(ctx, tx, usernameLower); err != nil {
|
||||
return err
|
||||
} else if reserved {
|
||||
return domain.ErrUsernameOccupied
|
||||
}
|
||||
var existing int64
|
||||
switch err := tx.QueryRow(ctx, `
|
||||
SELECT id FROM collectible_usernames
|
||||
|
|
|
|||
|
|
@ -61,7 +61,27 @@ func getPeerUsernameOwner(ctx context.Context, db sqlcgen.DBTX, usernameLower st
|
|||
return owner, true, nil
|
||||
}
|
||||
|
||||
// usernameReservedTx reports whether a name is on the operator blocklist. It is
|
||||
// consulted before every editable-username write and before a collectible mint.
|
||||
func usernameReservedTx(ctx context.Context, db sqlcgen.DBTX, usernameLower string) (bool, error) {
|
||||
if usernameLower == "" {
|
||||
return false, nil
|
||||
}
|
||||
var exists bool
|
||||
if err := db.QueryRow(ctx,
|
||||
`SELECT EXISTS (SELECT 1 FROM reserved_usernames WHERE username_lower = $1)`,
|
||||
usernameLower).Scan(&exists); err != nil {
|
||||
return false, fmt.Errorf("check reserved username: %w", err)
|
||||
}
|
||||
return exists, nil
|
||||
}
|
||||
|
||||
func peerUsernameAvailable(ctx context.Context, db sqlcgen.DBTX, usernameLower, peerType string, peerID int64) (bool, error) {
|
||||
if reserved, err := usernameReservedTx(ctx, db, usernameLower); err != nil {
|
||||
return false, err
|
||||
} else if reserved {
|
||||
return false, nil
|
||||
}
|
||||
owner, found, err := getPeerUsernameOwner(ctx, db, usernameLower, false)
|
||||
if err != nil || !found {
|
||||
return !found, err
|
||||
|
|
@ -111,6 +131,11 @@ WHERE peer_type = $1
|
|||
// otherwise account.updateUsername would silently release a minted asset.
|
||||
func replacePeerUsernameTx(ctx context.Context, tx pgx.Tx, peerType string, peerID int64, username, usernameLower string) error {
|
||||
if usernameLower != "" {
|
||||
if reserved, err := usernameReservedTx(ctx, tx, usernameLower); err != nil {
|
||||
return err
|
||||
} else if reserved {
|
||||
return domain.ErrUsernameOccupied
|
||||
}
|
||||
owner, found, err := getPeerUsernameOwner(ctx, tx, usernameLower, true)
|
||||
if err != nil {
|
||||
return err
|
||||
|
|
|
|||
99
internal/store/postgres/reserved_username.go
Normal file
99
internal/store/postgres/reserved_username.go
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store/postgres/sqlcgen"
|
||||
)
|
||||
|
||||
// ReservedUsernameStore is the operator username blocklist backed by the
|
||||
// reserved_usernames table.
|
||||
type ReservedUsernameStore struct {
|
||||
db sqlcgen.DBTX
|
||||
}
|
||||
|
||||
// NewReservedUsernameStore builds the store on a pgx pool or transaction.
|
||||
func NewReservedUsernameStore(db sqlcgen.DBTX) *ReservedUsernameStore {
|
||||
return &ReservedUsernameStore{db: db}
|
||||
}
|
||||
|
||||
func (s *ReservedUsernameStore) IsReserved(ctx context.Context, usernameLower string) (bool, error) {
|
||||
usernameLower = strings.ToLower(strings.TrimSpace(usernameLower))
|
||||
if usernameLower == "" {
|
||||
return false, nil
|
||||
}
|
||||
var exists bool
|
||||
if err := s.db.QueryRow(ctx,
|
||||
`SELECT EXISTS (SELECT 1 FROM reserved_usernames WHERE username_lower = $1)`,
|
||||
usernameLower).Scan(&exists); err != nil {
|
||||
return false, fmt.Errorf("check reserved username: %w", err)
|
||||
}
|
||||
return exists, nil
|
||||
}
|
||||
|
||||
func (s *ReservedUsernameStore) ReserveUsername(ctx context.Context, username, reason, actor string) (bool, error) {
|
||||
username = strings.TrimSpace(username)
|
||||
lower := strings.ToLower(username)
|
||||
if lower == "" {
|
||||
return false, domain.ErrUsernameInvalid
|
||||
}
|
||||
tag, err := s.db.Exec(ctx, `
|
||||
INSERT INTO reserved_usernames (username_lower, username, reason, actor)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (username_lower) DO NOTHING`, lower, username, reason, actor)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("reserve username: %w", err)
|
||||
}
|
||||
return tag.RowsAffected() > 0, nil
|
||||
}
|
||||
|
||||
func (s *ReservedUsernameStore) UnreserveUsername(ctx context.Context, username string) (bool, error) {
|
||||
lower := strings.ToLower(strings.TrimSpace(username))
|
||||
if lower == "" {
|
||||
return false, domain.ErrUsernameInvalid
|
||||
}
|
||||
tag, err := s.db.Exec(ctx, `DELETE FROM reserved_usernames WHERE username_lower = $1`, lower)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("unreserve username: %w", err)
|
||||
}
|
||||
return tag.RowsAffected() > 0, nil
|
||||
}
|
||||
|
||||
func (s *ReservedUsernameStore) ReservedUsernames(ctx context.Context, filter domain.ReservedUsernameFilter) ([]domain.ReservedUsername, error) {
|
||||
limit := filter.Limit
|
||||
if limit <= 0 || limit > 500 {
|
||||
limit = 100
|
||||
}
|
||||
offset := filter.Offset
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
args := []any{limit, offset}
|
||||
where := ""
|
||||
if q := strings.ToLower(strings.TrimSpace(filter.Query)); q != "" {
|
||||
args = append(args, q+"%")
|
||||
where = "WHERE username_lower LIKE $3"
|
||||
}
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT username, reason, actor, created_at
|
||||
FROM reserved_usernames
|
||||
`+where+`
|
||||
ORDER BY created_at DESC, username_lower
|
||||
LIMIT $1 OFFSET $2`, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list reserved usernames: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]domain.ReservedUsername, 0, limit)
|
||||
for rows.Next() {
|
||||
var item domain.ReservedUsername
|
||||
if err := rows.Scan(&item.Username, &item.Reason, &item.Actor, &item.CreatedAt); err != nil {
|
||||
return nil, fmt.Errorf("scan reserved username: %w", err)
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
22
internal/store/reserved_username.go
Normal file
22
internal/store/reserved_username.go
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// ReservedUsernameStore owns the operator username blocklist. IsReserved is the
|
||||
// hot path consulted on every editable-username write; the rest are the admin
|
||||
// lifecycle.
|
||||
type ReservedUsernameStore interface {
|
||||
// IsReserved reports whether usernameLower (already lowercased) is blocked.
|
||||
IsReserved(ctx context.Context, usernameLower string) (bool, error)
|
||||
// ReserveUsername adds an entry. Returns created=false if it already existed
|
||||
// (the existing reason/actor are kept).
|
||||
ReserveUsername(ctx context.Context, username, reason, actor string) (created bool, err error)
|
||||
// UnreserveUsername removes an entry. Returns removed=false if absent.
|
||||
UnreserveUsername(ctx context.Context, username string) (removed bool, err error)
|
||||
// ReservedUsernames pages the blocklist, newest first.
|
||||
ReservedUsernames(ctx context.Context, filter domain.ReservedUsernameFilter) ([]domain.ReservedUsername, error)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue