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
|
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
|
WORKDIR /src
|
||||||
COPY . .
|
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
|
RUN CGO_ENABLED=0 go build -trimpath -o /out/telesrv-admin ./cmd/telesrv-admin
|
||||||
|
|
||||||
FROM docker.io/library/alpine:3.20
|
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,
|
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(r.frozen, false), COALESCE(r.reason, ''), u.verified, u.scam, u.fake,
|
||||||
COALESCE(EXTRACT(EPOCH FROM u.premium_expires_at), 0)::bigint,
|
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(NULLIF(u.username, ''), p.username_lower, '') AS display_username,
|
||||||
COALESCE(ap.login_email, ''),
|
COALESCE(ap.login_email, ''),
|
||||||
`+accountCollectibleUsernamesColumn+` AS collectibles
|
`+accountCollectibleUsernamesColumn+` AS collectibles
|
||||||
FROM users u
|
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 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 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
|
LEFT JOIN account_passwords ap ON ap.user_id = u.id
|
||||||
WHERE NOT u.is_bot
|
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))
|
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 auth.last_active_at DESC, u.id DESC
|
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)
|
LIMIT $3`, beforeActiveUS, beforeID, limit+1)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, false, fmt.Errorf("list accounts: %w", err)
|
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 {
|
userID, userID, "+1889"+suffix, editable); err != nil {
|
||||||
t.Fatalf("seed user: %v", err)
|
t.Fatalf("seed user: %v", err)
|
||||||
}
|
}
|
||||||
// The list query joins authorizations, so an account with no device never
|
// Give this account a device so its device_count / last_active columns are
|
||||||
// appears there at all; an authorization in turn needs its auth key to exist.
|
// exercised; an authorization needs its auth key to exist first.
|
||||||
if _, err := pool.Exec(ctx, `
|
if _, err := pool.Exec(ctx, `
|
||||||
INSERT INTO auth_keys (auth_key_id, body, server_salt) VALUES ($1, '\x00', 0)`, userID); err != nil {
|
INSERT INTO auth_keys (auth_key_id, body, server_salt) VALUES ($1, '\x00', 0)`, userID); err != nil {
|
||||||
t.Fatalf("seed auth key: %v", err)
|
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) {
|
func assertCollectibles(t *testing.T, surface string, row AccountRow, editable string, want []AccountUsername) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
if row.Username != editable {
|
if row.Username != editable {
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import (
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"mime/multipart"
|
"mime/multipart"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"path"
|
"path"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"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", s.requireAuthAPI(http.HandlerFunc(s.handleGroupMessagesAPI)))
|
||||||
mux.Handle("GET /api/messages/groups/detail", s.requireAuthAPI(http.HandlerFunc(s.handleGroupMessageDetailAPI)))
|
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/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/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/stats", s.requireAuthAPI(http.HandlerFunc(s.handleStorageStatsAPI)))
|
||||||
mux.Handle("GET /api/storage/accounts", s.requireAuthAPI(http.HandlerFunc(s.handleStorageAccountsAPI)))
|
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/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-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/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/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/transfer-collectible-username", s.requireAuthAPI(http.HandlerFunc(s.handleTransferCollectibleUsernameAPI)))
|
||||||
mux.Handle("POST /api/actions/revoke-collectible-username", s.requireAuthAPI(http.HandlerFunc(s.handleRevokeCollectibleUsernameAPI)))
|
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"`
|
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) {
|
func (s *server) handleMintCollectibleUsernameAPI(w http.ResponseWriter, r *http.Request) {
|
||||||
var body mintCollectibleUsernameAPIRequest
|
var body mintCollectibleUsernameAPIRequest
|
||||||
if !decodeAction(w, r, &body) {
|
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>
|
||||||
|
|
||||||
<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">
|
<link rel="stylesheet" crossorigin href="/assets/index-CQKJMNpu.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ import type {
|
||||||
ChannelListResponse,
|
ChannelListResponse,
|
||||||
CollectibleUsernameDetail,
|
CollectibleUsernameDetail,
|
||||||
CollectibleUsernameListResponse,
|
CollectibleUsernameListResponse,
|
||||||
|
ReservedUsernameListResponse,
|
||||||
CommandResult,
|
CommandResult,
|
||||||
GroupMessageDetail,
|
GroupMessageDetail,
|
||||||
GroupMessageListResponse,
|
GroupMessageListResponse,
|
||||||
|
|
@ -163,6 +164,8 @@ export const api = {
|
||||||
request<CollectibleUsernameListResponse>(`/api/collectible-usernames?${params.toString()}`),
|
request<CollectibleUsernameListResponse>(`/api/collectible-usernames?${params.toString()}`),
|
||||||
collectibleUsername: (id: string) =>
|
collectibleUsername: (id: string) =>
|
||||||
request<CollectibleUsernameDetail>(`/api/collectible-usernames/${encodeURIComponent(id)}`),
|
request<CollectibleUsernameDetail>(`/api/collectible-usernames/${encodeURIComponent(id)}`),
|
||||||
|
reservedUsernames: (params: URLSearchParams) =>
|
||||||
|
request<ReservedUsernameListResponse>(`/api/reserved-usernames?${params.toString()}`),
|
||||||
dashboard: () => request<DashboardResponse>("/api/dashboard"),
|
dashboard: () => request<DashboardResponse>("/api/dashboard"),
|
||||||
storageStats: () => request<StorageStatsResponse>("/api/storage/stats"),
|
storageStats: () => request<StorageStatsResponse>("/api/storage/stats"),
|
||||||
storageAccounts: (params: URLSearchParams) =>
|
storageAccounts: (params: URLSearchParams) =>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import {
|
import {
|
||||||
AtSign,
|
AtSign,
|
||||||
BadgeCheck,
|
BadgeCheck,
|
||||||
|
Ban,
|
||||||
Bot,
|
Bot,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
Database,
|
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={<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={<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={<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={<Sticker size={16} />} href="/stickers" route={route} navigate={navigate}>{"Stickers"}</NavLink>
|
||||||
<NavLink icon={<Smile size={16} />} href="/emoji" route={route} navigate={navigate}>{"Emoji"}</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 { SharedDevicesPage } from "./SharedDevicesPage";
|
||||||
import { CollectibleUsernameDetailPage } from "./CollectibleUsernameDetailPage";
|
import { CollectibleUsernameDetailPage } from "./CollectibleUsernameDetailPage";
|
||||||
import { CollectibleUsernamesPage } from "./CollectibleUsernamesPage";
|
import { CollectibleUsernamesPage } from "./CollectibleUsernamesPage";
|
||||||
|
import { ReservedUsernamesPage } from "./ReservedUsernamesPage";
|
||||||
import { ChannelDetailPage } from "./ChannelDetailPage";
|
import { ChannelDetailPage } from "./ChannelDetailPage";
|
||||||
import { ChannelsPage } from "./ChannelsPage";
|
import { ChannelsPage } from "./ChannelsPage";
|
||||||
import { BotDetailPage } from "./BotDetailPage";
|
import { BotDetailPage } from "./BotDetailPage";
|
||||||
|
|
@ -82,6 +83,9 @@ export function Routes({ route, navigate }: { route: RouteState; navigate: Navig
|
||||||
if (route.path === "/collectible-usernames") {
|
if (route.path === "/collectible-usernames") {
|
||||||
return <CollectibleUsernamesPage navigate={navigate} />;
|
return <CollectibleUsernamesPage navigate={navigate} />;
|
||||||
}
|
}
|
||||||
|
if (route.path === "/reserved-usernames") {
|
||||||
|
return <ReservedUsernamesPage />;
|
||||||
|
}
|
||||||
if (route.path === "/storage") {
|
if (route.path === "/storage") {
|
||||||
return <StoragePage navigate={navigate} />;
|
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("/bot-verification")) return "Third-party verification";
|
||||||
if (pathname.startsWith("/verification")) return "Official Verification";
|
if (pathname.startsWith("/verification")) return "Official Verification";
|
||||||
if (pathname.startsWith("/collectible-usernames")) return "Collectible Usernames";
|
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("/storage")) return "Storage";
|
||||||
if (pathname.startsWith("/accounts/shared-devices")) return "Shared Devices";
|
if (pathname.startsWith("/accounts/shared-devices")) return "Shared Devices";
|
||||||
if (pathname.startsWith("/accounts")) return "Accounts";
|
if (pathname.startsWith("/accounts")) return "Accounts";
|
||||||
|
|
|
||||||
|
|
@ -347,6 +347,17 @@ export type CollectibleUsernameDetail = {
|
||||||
transfers: CollectibleUsernameTransferRow[] | null;
|
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
|
// Official platform verification. Every int64 the backend tags `,string` stays a
|
||||||
// decimal string here: application ids, peer ids and the optimistic-locking
|
// 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
|
// 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))
|
account.WithLoginEmailVerification(codeStore, loginEmailSender, cfg.AuthCodeTTL, cfg.AuthCodeMaxAttempts, cfg.LoginEmailCodeLength))
|
||||||
}
|
}
|
||||||
accountService := account.NewService(passwordStore, accountOptions...)
|
accountService := account.NewService(passwordStore, accountOptions...)
|
||||||
|
reservedUsernameStore := postgres.NewReservedUsernameStore(pool)
|
||||||
botsService := botsapp.NewService(userStore, botStore, messageStore,
|
botsService := botsapp.NewService(userStore, botStore, messageStore,
|
||||||
botsapp.WithLogger(logger.Named("bots")),
|
botsapp.WithLogger(logger.Named("bots")),
|
||||||
botsapp.WithBlockChecker(contactStore),
|
botsapp.WithBlockChecker(contactStore),
|
||||||
botsapp.WithPublicChannelUsernameResolver(channelStore),
|
botsapp.WithPublicChannelUsernameResolver(channelStore),
|
||||||
|
botsapp.WithReservedUsernames(reservedUsernameStore),
|
||||||
botsapp.WithUserCache(userCache),
|
botsapp.WithUserCache(userCache),
|
||||||
botsapp.WithStickerSetCreator(filesService),
|
botsapp.WithStickerSetCreator(filesService),
|
||||||
botsapp.WithGifCatalogSource(filesService),
|
botsapp.WithGifCatalogSource(filesService),
|
||||||
|
|
@ -1390,6 +1392,7 @@ func run(logger *zap.Logger) error {
|
||||||
Emoji: filesService,
|
Emoji: filesService,
|
||||||
Moderation: moderationService,
|
Moderation: moderationService,
|
||||||
Usernames: usernamesService,
|
Usernames: usernamesService,
|
||||||
|
ReservedUsernames: reservedUsernameStore,
|
||||||
Verification: verificationService,
|
Verification: verificationService,
|
||||||
BotVerification: botVerificationService,
|
BotVerification: botVerificationService,
|
||||||
Account: accountService,
|
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"
|
ActionTransferCollectibleUsername = "usernames.collectible.transfer"
|
||||||
ActionRevokeCollectibleUsername = "usernames.collectible.revoke"
|
ActionRevokeCollectibleUsername = "usernames.collectible.revoke"
|
||||||
ActionDeleteCollectibleUsername = "usernames.collectible.delete"
|
ActionDeleteCollectibleUsername = "usernames.collectible.delete"
|
||||||
|
// Operator username blocklist.
|
||||||
|
ActionReserveUsername = "usernames.reserve"
|
||||||
|
ActionUnreserveUsername = "usernames.unreserve"
|
||||||
// Official platform verification review. Claim/approve/reject act on one
|
// Official platform verification review. Claim/approve/reject act on one
|
||||||
// application; revoke acts on a target, because clearing a badge is not a
|
// application; revoke acts on a target, because clearing a badge is not a
|
||||||
// decision on the application that granted it.
|
// 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)
|
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
|
// collectibleUsernameByIDLookup is the optional by-identity read. Stores that
|
||||||
// expose it answer a detail request in one round trip; the keyset fallback in
|
// expose it answer a detail request in one round trip; the keyset fallback in
|
||||||
// CollectibleUsernameByID keeps a service without it correct.
|
// CollectibleUsernameByID keeps a service without it correct.
|
||||||
|
|
@ -389,6 +402,7 @@ type Dependencies struct {
|
||||||
Emoji EmojiService
|
Emoji EmojiService
|
||||||
Moderation ModerationService
|
Moderation ModerationService
|
||||||
Usernames CollectibleUsernamesService
|
Usernames CollectibleUsernamesService
|
||||||
|
ReservedUsernames ReservedUsernamesService
|
||||||
Verification VerificationService
|
Verification VerificationService
|
||||||
// BotVerification is the third-party mechanism, wired separately from
|
// BotVerification is the third-party mechanism, wired separately from
|
||||||
// Verification: the two never read each other's state.
|
// Verification: the two never read each other's state.
|
||||||
|
|
@ -420,6 +434,7 @@ type Service struct {
|
||||||
emoji EmojiService
|
emoji EmojiService
|
||||||
moderation ModerationService
|
moderation ModerationService
|
||||||
usernames CollectibleUsernamesService
|
usernames CollectibleUsernamesService
|
||||||
|
reservedUsernames ReservedUsernamesService
|
||||||
verification VerificationService
|
verification VerificationService
|
||||||
botVerification BotVerificationService
|
botVerification BotVerificationService
|
||||||
account AccountService
|
account AccountService
|
||||||
|
|
@ -487,6 +502,9 @@ func (s *Service) Configure(deps Dependencies) *Service {
|
||||||
if deps.Usernames != nil {
|
if deps.Usernames != nil {
|
||||||
s.usernames = deps.Usernames
|
s.usernames = deps.Usernames
|
||||||
}
|
}
|
||||||
|
if deps.ReservedUsernames != nil {
|
||||||
|
s.reservedUsernames = deps.ReservedUsernames
|
||||||
|
}
|
||||||
if deps.Verification != nil {
|
if deps.Verification != nil {
|
||||||
s.verification = deps.Verification
|
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) {
|
func collectibleOwnerPeer(userID, channelID int64) (domain.Peer, error) {
|
||||||
if userID < 0 || channelID < 0 {
|
if userID < 0 || channelID < 0 {
|
||||||
return domain.Peer{}, fmt.Errorf("owner id must be positive")
|
return domain.Peer{}, fmt.Errorf("owner id must be positive")
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import (
|
||||||
|
|
||||||
usernamesapp "telesrv/internal/app/usernames"
|
usernamesapp "telesrv/internal/app/usernames"
|
||||||
"telesrv/internal/domain"
|
"telesrv/internal/domain"
|
||||||
|
"telesrv/internal/store/memory"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Compile-time proof that the shipped use-case services satisfy the admin ports.
|
// 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")
|
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)
|
CollectibleUsernames(ctx context.Context, filter domain.CollectibleUsernameFilter) ([]domain.CollectibleUsername, error)
|
||||||
CollectibleUsernameByID(ctx context.Context, id int64) (domain.CollectibleUsername, error)
|
CollectibleUsernameByID(ctx context.Context, id int64) (domain.CollectibleUsername, error)
|
||||||
CollectibleUsernameTransfers(ctx context.Context, collectibleID int64, limit int) ([]domain.CollectibleUsernameTransfer, 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)
|
ClaimVerification(ctx context.Context, req admin.ClaimVerificationRequest) (admin.CommandResult, error)
|
||||||
ApproveVerification(ctx context.Context, req admin.ApproveVerificationRequest) (admin.CommandResult, error)
|
ApproveVerification(ctx context.Context, req admin.ApproveVerificationRequest) (admin.CommandResult, error)
|
||||||
RejectVerification(ctx context.Context, req admin.RejectVerificationRequest) (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("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", s.authenticated(s.handleCollectibleUsernames))
|
||||||
mux.HandleFunc("GET /v1/collectible-usernames/{id}", s.authenticated(s.handleCollectibleUsername))
|
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
|
// Official platform verification. Unlike every route above, these carry a
|
||||||
// named permission, so a scoped token can be given the review surface and
|
// named permission, so a scoped token can be given the review surface and
|
||||||
// nothing else. Revocation additionally requires verification.revoke.
|
// nothing else. Revocation additionally requires verification.revoke.
|
||||||
|
|
@ -1186,6 +1192,54 @@ func (s *Server) handleDeleteCollectibleUsername(w http.ResponseWriter, r *http.
|
||||||
writeCommandResult(w, result, err)
|
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) {
|
func (s *Server) handleCollectibleUsernames(w http.ResponseWriter, r *http.Request) {
|
||||||
query := r.URL.Query()
|
query := r.URL.Query()
|
||||||
filter := domain.CollectibleUsernameFilter{
|
filter := domain.CollectibleUsernameFilter{
|
||||||
|
|
|
||||||
|
|
@ -510,12 +510,30 @@ func (fakeService) ReviewModerationAppeal(context.Context, domain.ModerationDeci
|
||||||
|
|
||||||
type captureCollectibleUsernameService struct {
|
type captureCollectibleUsernameService struct {
|
||||||
fakeService
|
fakeService
|
||||||
mint admin.MintCollectibleUsernameRequest
|
mint admin.MintCollectibleUsernameRequest
|
||||||
transfer admin.TransferCollectibleUsernameRequest
|
transfer admin.TransferCollectibleUsernameRequest
|
||||||
revoke admin.RevokeCollectibleUsernameRequest
|
revoke admin.RevokeCollectibleUsernameRequest
|
||||||
del admin.DeleteCollectibleUsernameRequest
|
del admin.DeleteCollectibleUsernameRequest
|
||||||
filter domain.CollectibleUsernameFilter
|
reserve admin.ReserveUsernameRequest
|
||||||
assetID int64
|
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) {
|
func (s *captureCollectibleUsernameService) MintCollectibleUsername(_ context.Context, req admin.MintCollectibleUsernameRequest) (admin.CommandResult, error) {
|
||||||
|
|
@ -731,3 +749,49 @@ func (fakeService) CollectibleUsernameByID(context.Context, int64) (domain.Colle
|
||||||
func (fakeService) CollectibleUsernameTransfers(context.Context, int64, int) ([]domain.CollectibleUsernameTransfer, error) {
|
func (fakeService) CollectibleUsernameTransfers(context.Context, int64, int) ([]domain.CollectibleUsernameTransfer, error) {
|
||||||
return nil, nil
|
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 {
|
if cmd, ok := parseBotCommand(text); ok {
|
||||||
inValueStep := found && state.Step == botFatherStepValue
|
inValueStep := found && state.Step == botFatherStepValue
|
||||||
if !inValueStep || botFatherGlobalCommands[cmd] {
|
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)
|
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 {
|
func (s *Service) handleBotFatherCommand(ctx context.Context, userID int64, cmd string) botReply {
|
||||||
switch cmd {
|
switch cmd {
|
||||||
case "start", "help":
|
case "start", "help":
|
||||||
|
|
@ -1157,3 +1199,16 @@ func parseBotCommand(text string) (string, bool) {
|
||||||
}
|
}
|
||||||
return strings.ToLower(cmd), true
|
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) {
|
func TestMyBotsTokenAndRevoke(t *testing.T) {
|
||||||
svc, users, bots, messages := newTestService(t)
|
svc, users, bots, messages := newTestService(t)
|
||||||
owner := newOwner(t, users, "+2003")
|
owner := newOwner(t, users, "+2003")
|
||||||
|
|
|
||||||
|
|
@ -121,6 +121,7 @@ type Service struct {
|
||||||
messages store.MessageStore
|
messages store.MessageStore
|
||||||
blocker blockChecker
|
blocker blockChecker
|
||||||
channels publicChannelUsernameResolver
|
channels publicChannelUsernameResolver
|
||||||
|
reserved reservedUsernameChecker
|
||||||
stickers stickerSetCreator
|
stickers stickerSetCreator
|
||||||
installer userStickerSetInstaller
|
installer userStickerSetInstaller
|
||||||
aiChat aiChatGenerator
|
aiChat aiChatGenerator
|
||||||
|
|
@ -199,6 +200,21 @@ func WithBotAvatarStore(a botAvatarStore) Option {
|
||||||
|
|
||||||
// WithPublicChannelUsernameResolver 注入公开频道 username 查询能力,用于 bot
|
// WithPublicChannelUsernameResolver 注入公开频道 username 查询能力,用于 bot
|
||||||
// username 预检,避免 bot 与 public channel 产生同名可见入口。
|
// 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 {
|
func WithPublicChannelUsernameResolver(c publicChannelUsernameResolver) Option {
|
||||||
return func(s *Service) {
|
return func(s *Service) {
|
||||||
if c != nil {
|
if c != nil {
|
||||||
|
|
@ -533,6 +549,13 @@ func (s *Service) CheckUsername(ctx context.Context, ownerUserID int64, username
|
||||||
if !domain.ValidBotUsername(username) {
|
if !domain.ValidBotUsername(username) {
|
||||||
return false, domain.ErrBotUsernameInvalid
|
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 {
|
if _, found, err := s.users.ByUsername(ctx, username); err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
} else if found {
|
} 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) {
|
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)
|
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{
|
key := participantsCacheKey{
|
||||||
userID: userID,
|
userID: userID,
|
||||||
channelID: channelID,
|
channelID: channelID,
|
||||||
|
|
@ -75,15 +72,23 @@ func (s *Service) cachedParticipants(ctx context.Context, userID, channelID int6
|
||||||
offset: offset,
|
offset: offset,
|
||||||
limit: limit,
|
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)
|
hash, err := s.channelParticipantsHash(ctx, userID, channelID, key)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return domain.ChannelParticipantList{}, err
|
return domain.ChannelParticipantList{}, err
|
||||||
}
|
}
|
||||||
if hash == 0 {
|
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) {
|
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 {
|
if err != nil {
|
||||||
return domain.ChannelParticipantList{}, err
|
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) {
|
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 {
|
if filter.Kind == domain.ChannelParticipantsBots && s.bots != nil {
|
||||||
return s.getBotParticipants(ctx, userID, channelID, offset, limit)
|
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) {
|
func TestFullMegagroupAdminGrantFillsManageRanks(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
service := NewService(memory.NewChannelStore())
|
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) {
|
func TestListStoryPostableChannelsFiltersPostStoryRights(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
service := NewService(memory.NewChannelStore())
|
service := NewService(memory.NewChannelStore())
|
||||||
|
|
|
||||||
|
|
@ -356,6 +356,163 @@ func apiMediaUsesCaption(media map[string]any) bool {
|
||||||
return false
|
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 {
|
func apiChat(peer domain.Peer, users map[int64]domain.User) map[string]any {
|
||||||
switch peer.Type {
|
switch peer.Type {
|
||||||
case domain.PeerTypeUser:
|
case domain.PeerTypeUser:
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,9 @@ type WebAppService interface {
|
||||||
|
|
||||||
type GatewayService interface {
|
type GatewayService interface {
|
||||||
BotAPISelf(ctx context.Context, botID int64) (domain.User, error)
|
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)
|
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)
|
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)
|
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) {
|
switch strings.ToLower(method) {
|
||||||
case "getme":
|
case "getme":
|
||||||
h.getMe(w, r, botID)
|
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":
|
case "setmycommands":
|
||||||
h.setMyCommands(w, r, botID)
|
h.setMyCommands(w, r, botID)
|
||||||
case "deletemycommands":
|
case "deletemycommands":
|
||||||
|
|
@ -310,6 +319,81 @@ func (h *handler) getMe(w http.ResponseWriter, r *http.Request, botID int64) {
|
||||||
writeAPIOK(w, apiUser(u))
|
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) {
|
func (h *handler) getUpdates(w http.ResponseWriter, r *http.Request, botID int64) {
|
||||||
if h.gateway == nil {
|
if h.gateway == nil {
|
||||||
writeAPIError(w, http.StatusNotImplemented, "METHOD_NOT_FOUND")
|
writeAPIError(w, http.StatusNotImplemented, "METHOD_NOT_FOUND")
|
||||||
|
|
@ -1457,6 +1541,7 @@ func apiErrorDescription(err error) string {
|
||||||
"BUTTON_URL_INVALID",
|
"BUTTON_URL_INVALID",
|
||||||
"BOT_INVALID",
|
"BOT_INVALID",
|
||||||
"CHAT_ID_INVALID",
|
"CHAT_ID_INVALID",
|
||||||
|
"CHAT_NOT_FOUND",
|
||||||
"ENTITY_INVALID",
|
"ENTITY_INVALID",
|
||||||
"ENTITIES_TOO_LONG",
|
"ENTITIES_TOO_LONG",
|
||||||
"ENTITY_BOUNDS_INVALID",
|
"ENTITY_BOUNDS_INVALID",
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"mime/multipart"
|
"mime/multipart"
|
||||||
"net/http"
|
"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) {
|
func TestBotCommandsPreserveEphemeralFlag(t *testing.T) {
|
||||||
bots := &fakeBotAPIBots{profile: domain.BotProfile{BotUserID: 1001, TokenSecret: "secret"}}
|
bots := &fakeBotAPIBots{profile: domain.BotProfile{BotUserID: 1001, TokenSecret: "secret"}}
|
||||||
h := (&handler{bots: bots}).routes()
|
h := (&handler{bots: bots}).routes()
|
||||||
|
|
@ -1442,6 +1548,13 @@ func (f *fakeWebAppService) SavePreparedInlineMessageFromBotAPI(_ context.Contex
|
||||||
type fakeBotAPIGateway struct {
|
type fakeBotAPIGateway struct {
|
||||||
self domain.User
|
self domain.User
|
||||||
|
|
||||||
|
chat domain.BotAPIChat
|
||||||
|
chatErr error
|
||||||
|
chatChatID int64
|
||||||
|
memberCount int
|
||||||
|
member domain.BotAPIChatMember
|
||||||
|
memberErr error
|
||||||
|
|
||||||
updates []domain.UpdateEvent
|
updates []domain.UpdateEvent
|
||||||
updateBotID int64
|
updateBotID int64
|
||||||
updateOffset int64
|
updateOffset int64
|
||||||
|
|
@ -1502,6 +1615,19 @@ func (f *fakeBotAPIGateway) BotAPISelf(context.Context, int64) (domain.User, err
|
||||||
return f.self, nil
|
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) {
|
func (f *fakeBotAPIGateway) BotAPIUpdates(_ context.Context, botID int64, offset int64) ([]domain.UpdateEvent, error) {
|
||||||
f.updateBotID = botID
|
f.updateBotID = botID
|
||||||
f.updateOffset = offset
|
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
|
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
|
// ProjectChannelHistoryClearMessage returns the owner-local service-message
|
||||||
// projection for one channel history boundary. Identity fields from the shared
|
// projection for one channel history boundary. Identity fields from the shared
|
||||||
// source are retained when available, while all user payload, media, reply,
|
// 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
|
// officialUpdatesChannelMention is the public @username of the updates channel
|
||||||
// linked from the welcome message. It is carried both in the template text and
|
// 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.
|
// 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!"
|
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
|
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
|
// BotAPIUpdates returns durable update_id based events projected for the HTTP
|
||||||
// Bot API. New deployments use the dedicated Bot API queue; the legacy
|
// 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.
|
// 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) {
|
func TestMessagesEditChatCreatorTransfersWithoutChannelPts(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
userStore := memory.NewUserStore()
|
userStore := memory.NewUserStore()
|
||||||
|
|
|
||||||
|
|
@ -341,6 +341,7 @@ func (r *Router) onChannelsInviteToChannel(ctx context.Context, req *tg.Channels
|
||||||
return nil, channelInviteErr(err)
|
return nil, channelInviteErr(err)
|
||||||
}
|
}
|
||||||
r.invalidateChannelFullBotInfoCacheForChannel(res.Channel.ID)
|
r.invalidateChannelFullBotInfoCacheForChannel(res.Channel.ID)
|
||||||
|
r.invalidateChannelMembershipProjection(res.Channel.ID, channelMemberUserIDs(res.Members))
|
||||||
r.addOnlineChannelMemberships(res.Channel.ID, channelMemberUserIDs(res.Members)...)
|
r.addOnlineChannelMemberships(res.Channel.ID, channelMemberUserIDs(res.Members)...)
|
||||||
cache := newViewerPeerCache(r)
|
cache := newViewerPeerCache(r)
|
||||||
updates := r.channelOperationUpdatesWithPeerCache(ctx, userID, res, cache)
|
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)
|
return nil, channelInviteErr(err)
|
||||||
}
|
}
|
||||||
r.invalidateChannelFullBotInfoCacheForChannel(res.Channel.ID)
|
r.invalidateChannelFullBotInfoCacheForChannel(res.Channel.ID)
|
||||||
|
r.invalidateChannelMembershipProjection(res.Channel.ID, channelMemberUserIDs(res.Members))
|
||||||
r.addOnlineChannelMemberships(res.Channel.ID, channelMemberUserIDs(res.Members)...)
|
r.addOnlineChannelMemberships(res.Channel.ID, channelMemberUserIDs(res.Members)...)
|
||||||
updates := r.channelOperationUpdates(ctx, userID, res)
|
updates := r.channelOperationUpdates(ctx, userID, res)
|
||||||
r.pushChannelUpdates(ctx, userID, res.Channel.ID, res.Recipients, func(viewerUserID int64) *tg.Updates {
|
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)
|
return nil, channelAdminErr(err)
|
||||||
}
|
}
|
||||||
r.invalidateChannelFullBotInfoCacheForChannel(res.Channel.ID)
|
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.removeOnlineChannelMemberships(res.Channel.ID, userID)
|
||||||
r.recordChannelStateForUser(ctx, userID, res.Channel.ID, true)
|
r.recordChannelStateForUser(ctx, userID, res.Channel.ID, true)
|
||||||
updates := r.channelOperationUpdates(ctx, userID, res)
|
updates := r.channelOperationUpdates(ctx, userID, res)
|
||||||
|
|
@ -658,6 +665,7 @@ func (r *Router) onMessagesHideChatJoinRequest(ctx context.Context, req *tg.Mess
|
||||||
return nil, channelInviteErr(err)
|
return nil, channelInviteErr(err)
|
||||||
}
|
}
|
||||||
r.invalidateChannelFullBotInfoCacheForChannel(res.Channel.ID)
|
r.invalidateChannelFullBotInfoCacheForChannel(res.Channel.ID)
|
||||||
|
r.invalidateChannelMembershipProjection(res.Channel.ID, channelMemberUserIDs(res.Members))
|
||||||
r.addOnlineChannelMemberships(res.Channel.ID, channelMemberUserIDs(res.Members)...)
|
r.addOnlineChannelMemberships(res.Channel.ID, channelMemberUserIDs(res.Members)...)
|
||||||
updates := r.channelOperationUpdates(ctx, userID, res)
|
updates := r.channelOperationUpdates(ctx, userID, res)
|
||||||
r.appendPendingJoinRequestsUpdate(ctx, userID, updates, res.Channel)
|
r.appendPendingJoinRequestsUpdate(ctx, userID, updates, res.Channel)
|
||||||
|
|
@ -696,6 +704,7 @@ func (r *Router) onMessagesHideAllChatJoinRequests(ctx context.Context, req *tg.
|
||||||
return nil, channelInviteErr(err)
|
return nil, channelInviteErr(err)
|
||||||
}
|
}
|
||||||
r.invalidateChannelFullBotInfoCacheForChannel(res.Channel.ID)
|
r.invalidateChannelFullBotInfoCacheForChannel(res.Channel.ID)
|
||||||
|
r.invalidateChannelMembershipProjection(res.Channel.ID, channelMemberUserIDs(res.Members))
|
||||||
r.addOnlineChannelMemberships(res.Channel.ID, channelMemberUserIDs(res.Members)...)
|
r.addOnlineChannelMemberships(res.Channel.ID, channelMemberUserIDs(res.Members)...)
|
||||||
updates := r.channelOperationUpdates(ctx, userID, res)
|
updates := r.channelOperationUpdates(ctx, userID, res)
|
||||||
r.appendPendingJoinRequestsUpdate(ctx, userID, updates, res.Channel)
|
r.appendPendingJoinRequestsUpdate(ctx, userID, updates, res.Channel)
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,6 @@ package rpc
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"github.com/iamxvbaba/td/clock"
|
|
||||||
"github.com/iamxvbaba/td/proto"
|
|
||||||
"github.com/iamxvbaba/td/tg"
|
|
||||||
"go.uber.org/zap/zaptest"
|
|
||||||
"strings"
|
"strings"
|
||||||
appchannels "telesrv/internal/app/channels"
|
appchannels "telesrv/internal/app/channels"
|
||||||
appupdates "telesrv/internal/app/updates"
|
appupdates "telesrv/internal/app/updates"
|
||||||
|
|
@ -13,6 +9,11 @@ import (
|
||||||
"telesrv/internal/domain"
|
"telesrv/internal/domain"
|
||||||
"telesrv/internal/store/memory"
|
"telesrv/internal/store/memory"
|
||||||
"testing"
|
"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) {
|
func TestChannelRealtimeRecipientsPreferOnlineMembers(t *testing.T) {
|
||||||
|
|
@ -662,7 +663,7 @@ func TestChannelSendMessageWithUnresolvableMentionSucceeds(t *testing.T) {
|
||||||
t.Fatalf("create megagroup: %v", err)
|
t.Fatalf("create megagroup: %v", err)
|
||||||
}
|
}
|
||||||
peer := &tg.InputPeerChannel{ChannelID: created.Channel.ID, AccessHash: created.Channel.AccessHash}
|
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{
|
if _, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), &tg.MessagesSendMessageRequest{
|
||||||
Peer: peer,
|
Peer: peer,
|
||||||
Message: text,
|
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,
|
Count: count,
|
||||||
Topics: topics,
|
Topics: topics,
|
||||||
Messages: messages,
|
Messages: messages,
|
||||||
Chats: tgChannels(userID, channels),
|
Chats: r.forumTopicsChats(userID, view, channels),
|
||||||
Users: r.tgUsersForIDs(ctx, userID, userIDs),
|
Users: r.tgUsersForIDs(ctx, userID, userIDs),
|
||||||
Pts: view.Channel.Pts,
|
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 {
|
func tgForumGeneralTopic(viewerUserID int64, view domain.ChannelView, topic domain.ChannelForumTopic) *tg.ForumTopic {
|
||||||
return &tg.ForumTopic{
|
return &tg.ForumTopic{
|
||||||
My: view.Channel.CreatorUserID == viewerUserID && viewerUserID != 0,
|
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) {
|
func (r *Router) invalidateRPCProjectionForChannel(channelID int64) {
|
||||||
if r.channelFullProjectionCache != nil {
|
if r.channelFullProjectionCache != nil {
|
||||||
r.channelFullProjectionCache.DeleteChannel(channelID)
|
r.channelFullProjectionCache.DeleteChannel(channelID)
|
||||||
|
|
|
||||||
|
|
@ -540,18 +540,14 @@ func (s *ChannelStore) resolveChannelReplyLocked(req domain.SendChannelMessageRe
|
||||||
if req.ReplyTo.TopMessageID <= 0 || !channel.Forum {
|
if req.ReplyTo.TopMessageID <= 0 || !channel.Forum {
|
||||||
return nil, domain.ErrReplyMessageIDInvalid
|
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 := cloneMessageReply(req.ReplyTo)
|
||||||
reply.MessageID = 0
|
reply.MessageID = 0
|
||||||
reply.Peer = channelPeer
|
reply.Peer = channelPeer
|
||||||
reply.TopMessageID = topic.TopicID
|
|
||||||
reply.ForumTopic = true
|
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
|
return reply, nil
|
||||||
}
|
}
|
||||||
target, ok := s.findMessageLocked(req.ChannelID, req.ReplyTo.MessageID)
|
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 := cloneMessageReply(req.ReplyTo)
|
||||||
reply.MessageID = target.ID
|
reply.MessageID = target.ID
|
||||||
reply.Peer = channelPeer
|
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
|
reply.TopMessageID = target.ID
|
||||||
if target.ReplyTo != nil && target.ReplyTo.TopMessageID > 0 {
|
if target.ReplyTo != nil && target.ReplyTo.TopMessageID > 0 {
|
||||||
reply.TopMessageID = target.ReplyTo.TopMessageID
|
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 {
|
if req.ReplyTo.TopMessageID > 0 && req.ReplyTo.TopMessageID != reply.TopMessageID {
|
||||||
return nil, domain.ErrReplyMessageIDInvalid
|
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
|
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 {
|
func inactiveChannelDate(dialog domain.Dialog, channel domain.Channel, member domain.ChannelMember) int {
|
||||||
if dialog.TopMessageDate > 0 {
|
if dialog.TopMessageDate > 0 {
|
||||||
return dialog.TopMessageDate
|
return dialog.TopMessageDate
|
||||||
|
|
|
||||||
|
|
@ -128,6 +128,9 @@ func (s *ChannelStore) CheckUsername(_ context.Context, userID, channelID int64,
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
usernameLower := strings.ToLower(strings.TrimSpace(strings.TrimPrefix(username, "@")))
|
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 {
|
for id, channel := range s.channels {
|
||||||
if channel.Deleted || channel.Username == "" {
|
if channel.Deleted || channel.Username == "" {
|
||||||
continue
|
continue
|
||||||
|
|
@ -175,6 +178,13 @@ func (s *ChannelStore) UpdateUsername(ctx context.Context, req domain.UpdateChan
|
||||||
}
|
}
|
||||||
prevUsername := channel.Username
|
prevUsername := channel.Username
|
||||||
channel.Username = 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.channels[req.ChannelID] = channel
|
||||||
s.appendChannelAdminLogLocked(domain.ChannelAdminLogEvent{
|
s.appendChannelAdminLogLocked(domain.ChannelAdminLogEvent{
|
||||||
ChannelID: req.ChannelID,
|
ChannelID: req.ChannelID,
|
||||||
|
|
@ -184,6 +194,16 @@ func (s *ChannelStore) UpdateUsername(ctx context.Context, req domain.UpdateChan
|
||||||
PrevString: prevUsername,
|
PrevString: prevUsername,
|
||||||
NewString: username,
|
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
|
return channel, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -178,7 +178,7 @@ func (s *ChannelStore) GeneralForumTopic(_ context.Context, viewerUserID, channe
|
||||||
}
|
}
|
||||||
s.mu.RLock()
|
s.mu.RLock()
|
||||||
defer s.mu.RUnlock()
|
defer s.mu.RUnlock()
|
||||||
channel, member, err := s.channelAndMemberLocked(viewerUserID, channelID)
|
channel, member, _, err := s.channelForViewerLocked(viewerUserID, channelID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return domain.ChannelForumTopic{}, err
|
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) {
|
func (s *ChannelStore) ListForumTopics(_ context.Context, viewerUserID int64, filter domain.ChannelForumTopicFilter) (domain.ChannelForumTopicList, error) {
|
||||||
s.mu.RLock()
|
s.mu.RLock()
|
||||||
defer s.mu.RUnlock()
|
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 {
|
if err != nil {
|
||||||
return domain.ChannelForumTopicList{}, err
|
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) {
|
func (s *ChannelStore) GetForumTopicsByID(_ context.Context, viewerUserID, channelID int64, ids []int) (domain.ChannelForumTopicList, error) {
|
||||||
s.mu.RLock()
|
s.mu.RLock()
|
||||||
defer s.mu.RUnlock()
|
defer s.mu.RUnlock()
|
||||||
channel, member, err := s.channelAndMemberLocked(viewerUserID, channelID)
|
channel, member, _, err := s.channelForViewerLocked(viewerUserID, channelID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return domain.ChannelForumTopicList{}, err
|
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) {
|
func (s *ChannelStore) ListChannelReplies(_ context.Context, viewerUserID int64, filter domain.ChannelRepliesFilter) (domain.ChannelHistory, error) {
|
||||||
s.mu.RLock()
|
s.mu.RLock()
|
||||||
defer s.mu.RUnlock()
|
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 {
|
if err != nil {
|
||||||
return domain.ChannelHistory{}, err
|
return domain.ChannelHistory{}, err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,26 @@ type CollectibleUsernameStore struct {
|
||||||
transfers map[int64][]domain.CollectibleUsernameTransfer
|
transfers map[int64][]domain.CollectibleUsernameTransfer
|
||||||
// commands maps a provenance command key onto the asset it touched.
|
// commands maps a provenance command key onto the asset it touched.
|
||||||
commands map[string]int64
|
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
|
// 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
|
return false, domain.ErrUsernameInvalid
|
||||||
}
|
}
|
||||||
key := strings.ToLower(username)
|
key := strings.ToLower(username)
|
||||||
|
if s.nameReserved(key) {
|
||||||
|
return false, domain.ErrUsernameOccupied
|
||||||
|
}
|
||||||
if existing, ok := s.registry[key]; ok {
|
if existing, ok := s.registry[key]; ok {
|
||||||
if existing.peer == peer && existing.row.Editable {
|
if existing.peer == peer && existing.row.Editable {
|
||||||
if existing.row.Username == username {
|
if existing.row.Username == username {
|
||||||
|
|
@ -313,6 +336,9 @@ func (s *CollectibleUsernameStore) MintCollectibleUsername(_ context.Context, re
|
||||||
if _, ok := s.registry[key]; ok {
|
if _, ok := s.registry[key]; ok {
|
||||||
return domain.CollectibleUsername{}, false, domain.ErrUsernameOccupied
|
return domain.CollectibleUsername{}, false, domain.ErrUsernameOccupied
|
||||||
}
|
}
|
||||||
|
if s.nameReserved(key) {
|
||||||
|
return domain.CollectibleUsername{}, false, domain.ErrUsernameOccupied
|
||||||
|
}
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
purchaseDate := req.PurchaseDate
|
purchaseDate := req.PurchaseDate
|
||||||
if purchaseDate.IsZero() {
|
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 == "" {
|
if username == "" {
|
||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
|
if s.usernameRegistry != nil && s.usernameRegistry.nameReserved(username) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
s.mu.RLock()
|
s.mu.RLock()
|
||||||
defer s.mu.RUnlock()
|
defer s.mu.RUnlock()
|
||||||
for id, u := range s.byID {
|
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 {
|
if req.ReplyTo.TopMessageID <= 0 || !channel.Forum {
|
||||||
return nil, domain.ErrReplyMessageIDInvalid
|
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 := cloneMessageReply(req.ReplyTo)
|
||||||
reply.MessageID = 0
|
reply.MessageID = 0
|
||||||
reply.Peer = channelPeer
|
reply.Peer = channelPeer
|
||||||
reply.TopMessageID = topic.TopicID
|
|
||||||
reply.ForumTopic = true
|
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
|
return reply, nil
|
||||||
}
|
}
|
||||||
target, err := s.getChannelMessage(ctx, db, req.ChannelID, req.ReplyTo.MessageID)
|
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 := cloneMessageReply(req.ReplyTo)
|
||||||
reply.MessageID = target.ID
|
reply.MessageID = target.ID
|
||||||
reply.Peer = channelPeer
|
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
|
reply.TopMessageID = target.ID
|
||||||
if target.ReplyTo != nil && target.ReplyTo.TopMessageID > 0 {
|
if target.ReplyTo != nil && target.ReplyTo.TopMessageID > 0 {
|
||||||
reply.TopMessageID = target.ReplyTo.TopMessageID
|
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 {
|
if req.ReplyTo.TopMessageID > 0 && req.ReplyTo.TopMessageID != reply.TopMessageID {
|
||||||
return nil, domain.ErrReplyMessageIDInvalid
|
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
|
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) {
|
func visibleChannelTopAfter(ctx context.Context, db sqlcgen.DBTX, channelID int64, availableMinID int, fallbackDate int) (int, int, error) {
|
||||||
var id, date int
|
var id, date int
|
||||||
err := db.QueryRow(ctx, `
|
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)
|
return domain.CreateChannelResult{}, fmt.Errorf("commit import channel invite: %w", err)
|
||||||
}
|
}
|
||||||
committed = true
|
committed = true
|
||||||
|
s.invalidateChannelMembershipCaches(result.Channel.ID, req.UserID)
|
||||||
recipients, _ := s.ListActiveChannelMemberIDs(ctx, req.UserID, result.Channel.ID, 0)
|
recipients, _ := s.ListActiveChannelMemberIDs(ctx, req.UserID, result.Channel.ID, 0)
|
||||||
result.Recipients = recipients
|
result.Recipients = recipients
|
||||||
return result, nil
|
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)
|
return domain.CreateChannelResult{}, fmt.Errorf("commit invite channel: %w", err)
|
||||||
}
|
}
|
||||||
committed = true
|
committed = true
|
||||||
|
s.invalidateChannelMembershipCaches(channelID, invitedIDs...)
|
||||||
recipients, _ := s.ListActiveChannelMemberIDs(ctx, inviterUserID, channelID, 0)
|
recipients, _ := s.ListActiveChannelMemberIDs(ctx, inviterUserID, channelID, 0)
|
||||||
return domain.CreateChannelResult{Channel: channel, Members: members, Message: msg, Event: event, Recipients: recipients}, nil
|
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)
|
return domain.CreateChannelResult{}, fmt.Errorf("commit join channel: %w", err)
|
||||||
}
|
}
|
||||||
committed = true
|
committed = true
|
||||||
|
s.invalidateChannelMembershipCaches(channelID, userID)
|
||||||
recipients, _ := s.ListActiveChannelMemberIDs(ctx, userID, channelID, 0)
|
recipients, _ := s.ListActiveChannelMemberIDs(ctx, userID, channelID, 0)
|
||||||
return domain.CreateChannelResult{Channel: channel, Members: []domain.ChannelMember{member}, Message: msg, Event: event, Recipients: recipients}, nil
|
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)
|
return domain.CreateChannelResult{}, fmt.Errorf("commit leave channel: %w", err)
|
||||||
}
|
}
|
||||||
committed = true
|
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)
|
recipients = append(recipients, userID)
|
||||||
return domain.CreateChannelResult{Channel: channel, Members: members, Message: msg, Event: event, Recipients: recipients}, nil
|
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 {
|
if err := replacePeerUsernameTx(ctx, tx, peerUsernameTypeChannel, req.ChannelID, username, usernameLower); err != nil {
|
||||||
return domain.Channel{}, err
|
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)
|
return domain.Channel{}, fmt.Errorf("update channel username: %w", err)
|
||||||
}
|
}
|
||||||
if err := markUserChannelMemberIndexPublicTx(ctx, tx, req.ChannelID, username != ""); err != nil {
|
if err := markUserChannelMemberIndexPublicTx(ctx, tx, req.ChannelID, username != ""); err != nil {
|
||||||
return domain.Channel{}, err
|
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
|
prevUsername := channel.Username
|
||||||
channel.Username = username
|
channel.Username = username
|
||||||
if err := s.insertChannelAdminLogTx(ctx, tx, domain.ChannelAdminLogEvent{
|
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
|
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。
|
// NewChannelStore 基于 pgx 连接池(或事务)创建 ChannelStore。
|
||||||
func NewChannelStore(db sqlcgen.DBTX, opts ...ChannelStoreOption) *ChannelStore {
|
func NewChannelStore(db sqlcgen.DBTX, opts ...ChannelStoreOption) *ChannelStore {
|
||||||
s := &ChannelStore{db: db}
|
s := &ChannelStore{db: db}
|
||||||
|
|
|
||||||
|
|
@ -198,7 +198,7 @@ func (s *ChannelStore) GeneralForumTopic(ctx context.Context, viewerUserID, chan
|
||||||
if viewerUserID == 0 || channelID == 0 {
|
if viewerUserID == 0 || channelID == 0 {
|
||||||
return domain.ChannelForumTopic{}, domain.ErrChannelInvalid
|
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 {
|
if err != nil {
|
||||||
return domain.ChannelForumTopic{}, err
|
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) {
|
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 {
|
if err != nil {
|
||||||
return domain.ChannelForumTopicList{}, err
|
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) {
|
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 {
|
if err != nil {
|
||||||
return domain.ChannelForumTopicList{}, err
|
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) {
|
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 {
|
if err != nil {
|
||||||
return domain.ChannelHistory{}, err
|
return domain.ChannelHistory{}, err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -206,6 +206,11 @@ func (s *CollectibleUsernameStore) MintCollectibleUsername(ctx context.Context,
|
||||||
} else if found {
|
} else if found {
|
||||||
return domain.ErrUsernameOccupied
|
return domain.ErrUsernameOccupied
|
||||||
}
|
}
|
||||||
|
if reserved, err := usernameReservedTx(ctx, tx, usernameLower); err != nil {
|
||||||
|
return err
|
||||||
|
} else if reserved {
|
||||||
|
return domain.ErrUsernameOccupied
|
||||||
|
}
|
||||||
var existing int64
|
var existing int64
|
||||||
switch err := tx.QueryRow(ctx, `
|
switch err := tx.QueryRow(ctx, `
|
||||||
SELECT id FROM collectible_usernames
|
SELECT id FROM collectible_usernames
|
||||||
|
|
|
||||||
|
|
@ -61,7 +61,27 @@ func getPeerUsernameOwner(ctx context.Context, db sqlcgen.DBTX, usernameLower st
|
||||||
return owner, true, nil
|
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) {
|
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)
|
owner, found, err := getPeerUsernameOwner(ctx, db, usernameLower, false)
|
||||||
if err != nil || !found {
|
if err != nil || !found {
|
||||||
return !found, err
|
return !found, err
|
||||||
|
|
@ -111,6 +131,11 @@ WHERE peer_type = $1
|
||||||
// otherwise account.updateUsername would silently release a minted asset.
|
// 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 {
|
func replacePeerUsernameTx(ctx context.Context, tx pgx.Tx, peerType string, peerID int64, username, usernameLower string) error {
|
||||||
if usernameLower != "" {
|
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)
|
owner, found, err := getPeerUsernameOwner(ctx, tx, usernameLower, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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