Compare commits

...

40 commits

Author SHA1 Message Date
3328ccbeab Merge branch 'fix/reserved-usernames-checkusername' 2026-09-09 21:41:53 +01:00
d1108c61f1 usernames: report reserved names as taken in the check paths too
account.checkUsername / channels.checkUsername / bots.checkUsername said a
reserved name was available and only updateUsername rejected it. Add the
blocklist check to peerUsernameAvailable (covers account + channel, both
backends) and to bots.Service.CheckUsername, so the client shows "username is
taken" immediately.
2026-09-09 21:41:53 +01:00
66091ede72 Merge branch 'fix/reserved-usernames-actionflow' 2026-09-09 21:23:25 +01:00
25b1d6399c admin ui: reserved usernames use the standard dry-run/confirm flow
Both reserve and unreserve go through ActionButton now (reason -> dry-run ->
confirm, journalled) like every other admin action. The reserve modal keeps
just the username field and hands off; it autofocuses and echoes @<name> live
so the field being filled is unambiguous.
2026-09-09 21:23:25 +01:00
fa95dab29a Merge branch 'fix/reserved-usernames-modal' 2026-09-09 21:15:23 +01:00
681802e893 admin ui: self-contained reserve-username modal, plain @ text
The reserve modal delegated to a nested ActionButton, whose own flow modal
opened over it - the username field ended up behind it and the request preview
came through empty on confirm. Replace it with a modal that owns its username
and reason fields and posts the reserve/unreserve command directly. Render the
@ prefix as text, not an icon.
2026-09-09 21:15:23 +01:00
2f7ad81ae4 Merge branch 'fix/reserved-usernames-layout' 2026-09-09 21:11:52 +01:00
216dd151e4 admin ui: match the reserved-usernames page layout to the NFT page
Move "Reserve username" into a modal opened from the page actions, and keep a
single search toolbar in the query panel, so the page matches Collectible
Usernames instead of stacking two toolbars with an unconstrained input.
2026-09-09 21:11:52 +01:00
be8afdd7d9 Merge branch 'feat/reserved-usernames' 2026-09-09 19:57:14 +01:00
a83aa45fb8 usernames: operator reserved-username blocklist
A plain blocklist for names like @support - separate from the collectible
system, so a reservation has no owner, no price and no "bought on Fragment"
badge.

- reserved_usernames table + migration.
- Enforced in replacePeerUsernameTx (the single editable-username write point:
  account.updateUsername, channels.updateUsername, @BotFather /setusername) and
  in the collectible mint path; a reserved name returns USERNAME_OCCUPIED.
- admin.Service: ReserveUsername / UnreserveUsername (journalled commands) and
  the ReservedUsernames listing.
- adminapi: /v1/reserved-usernames{,/reserve,/unreserve}.
- telesrv-admin panel + a "Reserved Usernames" page in the web UI (dist rebuilt).
- Postgres and in-memory store implementations; the memory registry gains an
  optional reserved-name check so tests exercise the same rule.
2026-09-09 19:57:14 +01:00
d2ffaa92bf Merge branch 'feat/botapi-chat-members' 2026-09-09 16:26:33 +01:00
11dd7660c0 botapi: getChatMemberCount, getChatMember, and a fuller getChat
- getChatMemberCount: channel/supergroup participant count (numeric chat_id).
- getChatMember: resolves a member via GetParticipant, projected to a Bot API
  ChatMember (creator/administrator/restricted/member/left/kicked with the
  matching rights); a user simply not in an accessible chat returns "left".
- getChat now uses the full channel view and adds permissions (from the default
  restrictions), slow_mode_delay, linked_chat_id and pinned_message.

Channel-only methods reject user chat_ids; private chats the bot cannot access
return CHAT_NOT_FOUND.
2026-09-09 16:26:33 +01:00
d641725622 Merge branch 'feat/botapi-getchat' 2026-09-09 16:14:49 +01:00
d022521d67 botapi: implement getChat
Adds the getChat method to the HTTP Bot API gateway. Numeric chat_id only (no
@username). Resolution goes through the shared peer resolvers:

- user: ByID; unknown -> CHAT_NOT_FOUND
- channel/supergroup: ResolveChannel, so a public one resolves even when the bot
  is not a member (projected as a preview); a private one the bot cannot access
  -> CHAT_NOT_FOUND, a banned bot likewise

The Chat projection returns id (bot-api encoded), type ("channel" for a
broadcast, "supergroup" for a megagroup, "private" for a user), title, username,
first/last name, description, is_forum, and the scam/fake/verified flags.
2026-09-09 16:14:49 +01:00
78267ee0d3 Merge branch 'chore/build-sh-port-2500' 2026-09-09 16:11:07 +01:00
fade2bca67 build: map port 2500 on the pod 2026-09-09 16:11:07 +01:00
1b7efc50e9 Merge branch 'chore/build-sh-pod-create' 2026-09-09 15:07:25 +01:00
f8f2c4bad4 build: create the pod with its port mappings if missing
Instead of erroring when the owpengram pod is absent, build.sh now creates it
with the MTProto (2398), admin (2600), extra TCP (2400) and RTC/UDP port
mappings.
2026-09-09 15:07:25 +01:00
083e54e145 Merge branch 'chore/build-sh-deploy' 2026-09-09 15:06:19 +01:00
57a0c5ef23 build: recreate and start the pod containers after building
build.sh now, after building the image, recreates owpengram-server and
owpengram-admin in the pod (podman create --replace) and starts them. Guards
that the pod and .env exist; NO_DEPLOY=1 keeps the old build-only behaviour,
POD overrides the pod name.
2026-09-09 15:06:19 +01:00
e927d4d18a Merge branch 'fix/botfather-start-manage-bot' 2026-09-09 15:03:53 +01:00
86f9b61336 botfather: /start <bot> opens that bot's menu
The "Manage Bot" button on a bot's profile deep-links to @BotFather with
start=<bot username>. parseBotCommand dropped the argument, so /start <bot>
just replied with the generic greeting instead of the per-bot menu.

Route "/start <arg>" to the bot's "What do you want to do?" screen (same as
/mybots then tapping the bot) when <arg> names one of the sender's own bots by
username or id; empty or unknown args keep the greeting.
2026-09-09 15:03:53 +01:00
236e9ed25a Merge branch 'fix/admin-accounts-list-sessionless' 2026-09-09 14:56:10 +01:00
c97255cb48 admin: list accounts that have no active sessions
The Accounts tab (readStore.ListAccounts) inner-joined the authorizations
aggregate, so any account with zero authorization rows was silently hidden -
accounts that never finished login, had all sessions revoked, or were frozen
then unfrozen. CountAccounts and SearchAccounts already LEFT JOIN, so the count
and search disagreed with the list.

Switch ListAccounts to LEFT JOIN auth and COALESCE the null last_active_at /
device_count (sessionless accounts sort last), matching SearchAccounts.
2026-09-09 14:56:10 +01:00
e19ad9960e Merge branch 'fix/forum-topic-replies-preview' 2026-09-09 14:45:06 +01:00
f6d7ef4652 forum: let non-members preview topic replies in a public channel
ListChannelReplies used getChannelForMemberOrLinkedGuest, so messages.getReplies
was member-only. ListChannelHistory (flat history) uses getChannelForViewer and
already allows a public channel's non-members to preview it. The mismatch meant
that on a public forum you could preview the flat history but not the topics -
and after leaving, tdesktop's topic view got CHANNEL_PRIVATE and sat on
"Loading..." forever instead of rendering a preview.

Switch the primary channel lookup in ListChannelReplies (both stores) to the
viewer-scope path. Private channels still return CHANNEL_PRIVATE to non-members;
the broadcast comment-thread lookup is unchanged.
2026-09-09 14:43:59 +01:00
f7a583813b Merge branch 'build-metadata-stamping' 2026-09-09 14:27:28 +01:00
f0479ecf9a build: stamp git metadata into the container image
.containerignore excludes .git, so go build's automatic VCS stamping produced
nothing and telesrv logged git_commit/git_branch/git_tree_state/build_time as
"unknown" on startup.

- Containerfile: accept GIT_COMMIT/GIT_BRANCH/GIT_TREE_STATE/BUILD_TIME build
  args and pass them to the gramsrv build via -ldflags -X.
- build.sh: wrapper that fills those args from the current checkout and runs
  podman build.
2026-09-09 14:26:58 +01:00
ef25da6462 Merge pull request 'forum: project the forum's own channel with member state in getForumTopics' (#5) from fix/forum-topics-nonmember-left into main
Reviewed-on: #5
2026-09-09 14:53:56 +02:00
a9fbb1f80d forum: project the forum's own channel with member state in getForumTopics
messages.getForumTopics returned every chat via tgChannels -> tgChannelChatMin,
so the forum's own channel came back as a min object with left unset. A client
with no other object for that peer (a fresh account browsing a public forum by
username) then rendered the forum as already joined: topic list visible, no
Join button, but no messages.

Render the primary channel with tgChannelChatForView so a non-member preview
carries left=true; keep the other referenced channels as min.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 13:53:20 +01:00
b52d3f3f64 Merge pull request 'welcome message: point updates-channel mention at @ziodotsh' (#4) from welcome-mention-ziodotsh into main
Reviewed-on: #4
2026-09-09 14:49:49 +02:00
69d64f3d80 welcome message: point updates-channel mention at @ziodotsh
Rename the official updates channel mention from @zio to @ziodotsh in the
welcome message template and update the affected send-message test.
2026-09-09 13:49:07 +01:00
63c565e490 Merge pull request 'channels: drop stale membership caches on join/leave' (#3) from fix/stale-membership-after-leave into main
Reviewed-on: #3
2026-09-09 14:34:04 +02:00
2419f47236 channels: drop stale membership caches on join/leave
After channels.leaveChannel, a client that polls channels.getFullChannel kept
receiving a projection that still showed it as an active member (left=false)
until the per-(viewer,channel) RPC projection cache and the store-level member
cache lapsed on their own or the async read-model NOTIFY landed. The client
therefore kept an open compose box while every send was already rejected with
CHANNEL_PRIVATE - most visible on public forum supergroups, where getFullChannel
keeps succeeding via the preview path instead of tearing the chat down.

Every other membership-mutating path already busts these caches synchronously;
join/leave/invite/request-approval did not. Add:

- store: invalidateChannelMembershipCaches (row + member + dialog caches),
  called post-commit from JoinChannel, LeaveChannel, ImportInvite,
  InviteToChannel.
- rpc: invalidateChannelMembershipProjection (channelFullProjectionCache pair),
  called from the join/leave/invite/hide-requests handlers for every user whose
  membership changed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 13:28:50 +01:00
d55a97f0a5 Merge pull request 'channels: force pre-history visible when a group gets a public username' (#2) from fix/prehistory-visible-on-publish into main
Reviewed-on: #2
2026-09-09 13:52:52 +02:00
df15c3ffb3 channels: force pre-history visible when a group gets a public username
New supergroups are created with "chat history for new members" hidden (the
client sets this right after creation, matching official Telegram). The
official server then forces it back to visible when the group is made public;
owpengram's UpdateUsername left the flag alone, leaving public groups in a
state where non-members (and post-join members) see no history at all.

UpdateUsername now clears pre_history_hidden whenever a non-empty username is
assigned, in the same transaction, with a matching admin-log event. Removing
the username leaves the flag untouched, so the creator can hide history again
once the group is private.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 12:51:23 +01:00
c376262cff Merge pull request 'channels: give getParticipants a stable Hash when read-model versions are missing' (#1) from fix/channel-participants-fallback-hash into main
Reviewed-on: astra/owpengram#1
2026-09-09 13:27:20 +02:00
5f63240f2d channels: give getParticipants a stable Hash when read-model versions are missing
cachedParticipants returned a participant page with Hash=0 whenever the
channel_base / channel_participants rows in read_model_versions were never
seeded for a channel (e.g. groups created via messages.createChat). With
Hash=0 the RPC layer can never answer channels.channelParticipantsNotModified,
so a client that polls the member list re-fetches it in a tight loop forever.

Fall back to a deterministic content hash derived from the page itself
(channel id, page key, count, and each member's id/role/status/rank) so an
unchanged member list yields an identical non-zero Hash and the client
converges. The read-model-backed path is unchanged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 12:26:30 +01:00
41f65bf0fc forum: fix reply_to_top_id for replies inside a forum
resolveChannelReply applied discussion-thread logic (reply_to_top_id =
the replied-to message's own id) to forum replies. Replying to a General
message produced reply_to_top_id = <that id>, a topic no client can
resolve: the reply vanished from every topic view and reply-jump on
strict clients said "message doesn't exist".

Forum replies now inherit the target's topic via domain.ForumReplyTopicID
(target's topic, or its own id if it's a topic-create, else General), and
General (topic 1) is accepted as a valid virtual topic everywhere, so
sends carrying top_msg_id: 1 are no longer rejected. Non-forum discussion
threads are unchanged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 11:15:56 +01:00
443ca300b9 forum: let non-members browse a public forum's topic list
ListForumTopics / GetForumTopicsByID / GeneralForumTopic gated on
membership while channel history uses the public-preview path, so a
public forum's topics (General included) were invisible until you joined.
Switch them to getChannelForViewer / channelForViewerLocked; private
forums and write paths keep the membership gate.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 11:15:56 +01:00
61 changed files with 2447 additions and 111 deletions

View file

@ -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
View 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}}'

View file

@ -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)

View file

@ -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 {

View file

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

File diff suppressed because one or more lines are too long

View file

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

View file

@ -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) =>

View file

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

View 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,
);
}

View file

@ -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} />;
} }

View file

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

View file

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

View file

@ -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,

View file

@ -0,0 +1 @@
DROP TABLE IF EXISTS public.reserved_usernames;

View 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);

View file

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

View file

@ -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)
}
}

View file

@ -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{

View file

@ -514,10 +514,28 @@ type captureCollectibleUsernameService struct {
transfer admin.TransferCollectibleUsernameRequest transfer admin.TransferCollectibleUsernameRequest
revoke admin.RevokeCollectibleUsernameRequest revoke admin.RevokeCollectibleUsernameRequest
del admin.DeleteCollectibleUsernameRequest del admin.DeleteCollectibleUsernameRequest
reserve admin.ReserveUsernameRequest
unreserve admin.UnreserveUsernameRequest
resFilter domain.ReservedUsernameFilter
filter domain.CollectibleUsernameFilter filter domain.CollectibleUsernameFilter
assetID int64 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) {
s.mint = req s.mint = req
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
@ -731,3 +749,49 @@ func (fakeService) CollectibleUsernameByID(context.Context, int64) (domain.Colle
func (fakeService) CollectibleUsernameTransfers(context.Context, int64, int) ([]domain.CollectibleUsernameTransfer, error) { 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)
}
}

View file

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

View file

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

View file

@ -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 {

View file

@ -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)

View file

@ -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())

View file

@ -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:

View file

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

View file

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

View 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
}

View file

@ -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,

View 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
}

View file

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

View file

@ -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.

View file

@ -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()

View file

@ -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)

View file

@ -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,

View 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")
}
}

View 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")
}
}

View file

@ -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,

View file

@ -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)

View file

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

View file

@ -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
} }

View file

@ -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
} }

View file

@ -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
} }

View file

@ -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() {

View 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
}

View 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)
}
}

View file

@ -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 {

View file

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

View file

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

View file

@ -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
} }

View file

@ -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
} }

View file

@ -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{

View file

@ -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}

View file

@ -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
} }

View file

@ -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
} }

View file

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

View file

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

View 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()
}

View 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)
}